@oh-my-pi/pi-coding-agent 5.6.7 → 5.6.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.6.70] - 2026-01-18
6
+ ### Added
7
+
8
+ - Added support for loading Python prelude extension modules from user and project directories
9
+ - Added automatic discovery of Python modules from `.omp/modules` and `.pi/modules` directories
10
+ - Added prioritized module loading with project-level modules overriding user-level modules
11
+
5
12
  ## [5.6.7] - 2026-01-18
6
13
 
7
14
  ### Added
@@ -112,10 +119,6 @@
112
119
  - Improved system prompt guidance for position-addressed vs content-addressed file edits
113
120
  - Enhanced edit tool documentation with clear use cases for bash alternatives
114
121
 
115
- ## [5.4.0] - 2026-01-15
116
-
117
- ## [5.3.1] - 2026-01-15
118
-
119
122
  ## [5.3.0] - 2026-01-15
120
123
  ### Changed
121
124
 
@@ -178,8 +181,6 @@
178
181
 
179
182
  - Implemented `xhigh` thinking level for Anthropic models with increased reasoning limits
180
183
 
181
- ## [4.9.0] - 2026-01-12
182
-
183
184
  ## [4.8.3] - 2026-01-12
184
185
 
185
186
  ### Changed
@@ -222,18 +223,12 @@
222
223
  - Component `invalidate()` now properly rebuilds content on theme changes
223
224
  - Force full re-render after returning from external editor
224
225
 
225
- ## [4.5.0] - 2026-01-12
226
-
227
- ## [4.4.9] - 2026-01-12
228
-
229
226
  ## [4.4.8] - 2026-01-12
230
227
  ### Changed
231
228
 
232
229
  - Changed review finding priority format from numeric (0-3) to string labels (P0-P3) for clearer severity indication
233
230
  - Replaced Type.Union with Type.Literal patterns with StringEnum helper across tool schemas for cleaner enum definitions
234
231
 
235
- ## [4.4.6] - 2026-01-11
236
-
237
232
  ## [4.4.5] - 2026-01-11
238
233
 
239
234
  ### Changed
@@ -514,8 +509,6 @@
514
509
  - Ctrl+V clipboard image paste works on Wayland sessions
515
510
  - Extension directories in `settings.json` respect `package.json` manifests
516
511
 
517
- ## [3.37.1] - 2026-01-10
518
-
519
512
  ## [3.37.0] - 2026-01-10
520
513
  ### Changed
521
514
 
@@ -973,8 +966,6 @@
973
966
 
974
967
  - Changed status line to display usage statistics more efficiently by using centralized session statistics instead of recalculating from entries
975
968
 
976
- ## [3.13.1337] - 2026-01-04
977
-
978
969
  ## [3.9.1337] - 2026-01-04
979
970
 
980
971
  ### Changed
@@ -1017,8 +1008,6 @@
1017
1008
 
1018
1009
  - Fixed potential text decoding issues in bash executor by using streaming TextDecoder instead of Buffer.toString()
1019
1010
 
1020
- ## [3.6.1337] - 2026-01-03
1021
-
1022
1011
  ## [3.5.1337] - 2026-01-03
1023
1012
 
1024
1013
  ### Added
@@ -1097,10 +1086,6 @@
1097
1086
  - Renamed environment variables from `PI_*` to `OMP_*` prefix (e.g., `OMP_SMOL_MODEL`, `OMP_SLOW_MODEL`)
1098
1087
  - Changed model role alias prefix from `pi/` to `omp/` (e.g., `omp/slow` instead of `pi/slow`)
1099
1088
 
1100
- ## [2.3.1337] - 2026-01-03
1101
-
1102
- ## [2.2.1337] - 2026-01-03
1103
-
1104
1089
  ## [2.1.1337] - 2026-01-03
1105
1090
 
1106
1091
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "5.6.7",
3
+ "version": "5.6.77",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "5.6.7",
43
- "@oh-my-pi/pi-ai": "5.6.7",
44
- "@oh-my-pi/pi-git-tool": "5.6.7",
45
- "@oh-my-pi/pi-tui": "5.6.7",
42
+ "@oh-my-pi/pi-agent-core": "5.6.77",
43
+ "@oh-my-pi/pi-ai": "5.6.77",
44
+ "@oh-my-pi/pi-git-tool": "5.6.77",
45
+ "@oh-my-pi/pi-tui": "5.6.77",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@silvia-odwyer/photon-node": "^0.3.4",
48
48
  "@sinclair/typebox": "^0.34.46",
@@ -67,6 +67,39 @@ function decodeMessage(data: ArrayBuffer): JupyterMessage {
67
67
  return JSON.parse(msgText) as JupyterMessage;
68
68
  }
69
69
 
70
+ function sendOkExecution(ws: FakeWebSocket, msgId: string, executionCount = 1) {
71
+ const reply: JupyterMessage = {
72
+ channel: "shell",
73
+ header: {
74
+ msg_id: `reply-${msgId}`,
75
+ session: "session",
76
+ username: "omp",
77
+ date: new Date().toISOString(),
78
+ msg_type: "execute_reply",
79
+ version: "5.5",
80
+ },
81
+ parent_header: { msg_id: msgId },
82
+ metadata: {},
83
+ content: { status: "ok", execution_count: executionCount },
84
+ };
85
+ const status: JupyterMessage = {
86
+ channel: "iopub",
87
+ header: {
88
+ msg_id: `status-${msgId}`,
89
+ session: "session",
90
+ username: "omp",
91
+ date: new Date().toISOString(),
92
+ msg_type: "status",
93
+ version: "5.5",
94
+ },
95
+ parent_header: { msg_id: msgId },
96
+ metadata: {},
97
+ content: { execution_state: "idle" },
98
+ };
99
+ ws.onmessage?.({ data: encodeMessage(reply) });
100
+ ws.onmessage?.({ data: encodeMessage(status) });
101
+ }
102
+
70
103
  class FakeWebSocket {
71
104
  static OPEN = 1;
72
105
  static CLOSED = 3;
@@ -149,106 +182,7 @@ describe("PythonKernel (external gateway)", () => {
149
182
  });
150
183
  globalThis.fetch = fetchMock as unknown as typeof fetch;
151
184
 
152
- const responseQueue: Array<(msgId: string, ws: FakeWebSocket) => void> = [];
153
- responseQueue.push((msgId, ws) => {
154
- const reply: JupyterMessage = {
155
- channel: "shell",
156
- header: {
157
- msg_id: "reply-1",
158
- session: "session",
159
- username: "omp",
160
- date: new Date().toISOString(),
161
- msg_type: "execute_reply",
162
- version: "5.5",
163
- },
164
- parent_header: { msg_id: msgId },
165
- metadata: {},
166
- content: { status: "ok", execution_count: 1 },
167
- };
168
- const status: JupyterMessage = {
169
- channel: "iopub",
170
- header: {
171
- msg_id: "status-1",
172
- session: "session",
173
- username: "omp",
174
- date: new Date().toISOString(),
175
- msg_type: "status",
176
- version: "5.5",
177
- },
178
- parent_header: { msg_id: msgId },
179
- metadata: {},
180
- content: { execution_state: "idle" },
181
- };
182
- ws.onmessage?.({ data: encodeMessage(reply) });
183
- ws.onmessage?.({ data: encodeMessage(status) });
184
- });
185
- responseQueue.push((msgId, ws) => {
186
- const stream: JupyterMessage = {
187
- channel: "iopub",
188
- header: {
189
- msg_id: "stream-1",
190
- session: "session",
191
- username: "omp",
192
- date: new Date().toISOString(),
193
- msg_type: "stream",
194
- version: "5.5",
195
- },
196
- parent_header: { msg_id: msgId },
197
- metadata: {},
198
- content: { text: "hello\n" },
199
- };
200
- const display: JupyterMessage = {
201
- channel: "iopub",
202
- header: {
203
- msg_id: "display-1",
204
- session: "session",
205
- username: "omp",
206
- date: new Date().toISOString(),
207
- msg_type: "execute_result",
208
- version: "5.5",
209
- },
210
- parent_header: { msg_id: msgId },
211
- metadata: {},
212
- content: {
213
- data: {
214
- "text/plain": "result",
215
- "application/json": { answer: 42 },
216
- },
217
- },
218
- };
219
- const reply: JupyterMessage = {
220
- channel: "shell",
221
- header: {
222
- msg_id: "reply-2",
223
- session: "session",
224
- username: "omp",
225
- date: new Date().toISOString(),
226
- msg_type: "execute_reply",
227
- version: "5.5",
228
- },
229
- parent_header: { msg_id: msgId },
230
- metadata: {},
231
- content: { status: "ok", execution_count: 2 },
232
- };
233
- const status: JupyterMessage = {
234
- channel: "iopub",
235
- header: {
236
- msg_id: "status-2",
237
- session: "session",
238
- username: "omp",
239
- date: new Date().toISOString(),
240
- msg_type: "status",
241
- version: "5.5",
242
- },
243
- parent_header: { msg_id: msgId },
244
- metadata: {},
245
- content: { execution_state: "idle" },
246
- };
247
- ws.onmessage?.({ data: encodeMessage(stream) });
248
- ws.onmessage?.({ data: encodeMessage(display) });
249
- ws.onmessage?.({ data: encodeMessage(reply) });
250
- ws.onmessage?.({ data: encodeMessage(status) });
251
- });
185
+ let preludeSeen = false;
252
186
 
253
187
  const kernelPromise = PythonKernel.start({ cwd: "/" });
254
188
  await Bun.sleep(10);
@@ -256,11 +190,84 @@ describe("PythonKernel (external gateway)", () => {
256
190
  if (!ws) throw new Error("WebSocket not initialized");
257
191
  ws.setSendHandler((data) => {
258
192
  const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
259
- const handler = responseQueue.shift();
260
- if (!handler) {
261
- throw new Error(`Unexpected message: ${msg.header.msg_type}`);
193
+ const code = String(msg.content.code ?? "");
194
+ if (!preludeSeen) {
195
+ expect(code).toBe(PYTHON_PRELUDE);
196
+ preludeSeen = true;
197
+ sendOkExecution(ws, msg.header.msg_id);
198
+ return;
262
199
  }
263
- handler(msg.header.msg_id, ws);
200
+
201
+ if (code === "print('hello')") {
202
+ const stream: JupyterMessage = {
203
+ channel: "iopub",
204
+ header: {
205
+ msg_id: "stream-1",
206
+ session: "session",
207
+ username: "omp",
208
+ date: new Date().toISOString(),
209
+ msg_type: "stream",
210
+ version: "5.5",
211
+ },
212
+ parent_header: { msg_id: msg.header.msg_id },
213
+ metadata: {},
214
+ content: { text: "hello\n" },
215
+ };
216
+ const display: JupyterMessage = {
217
+ channel: "iopub",
218
+ header: {
219
+ msg_id: "display-1",
220
+ session: "session",
221
+ username: "omp",
222
+ date: new Date().toISOString(),
223
+ msg_type: "execute_result",
224
+ version: "5.5",
225
+ },
226
+ parent_header: { msg_id: msg.header.msg_id },
227
+ metadata: {},
228
+ content: {
229
+ data: {
230
+ "text/plain": "result",
231
+ "application/json": { answer: 42 },
232
+ },
233
+ },
234
+ };
235
+ const reply: JupyterMessage = {
236
+ channel: "shell",
237
+ header: {
238
+ msg_id: "reply-2",
239
+ session: "session",
240
+ username: "omp",
241
+ date: new Date().toISOString(),
242
+ msg_type: "execute_reply",
243
+ version: "5.5",
244
+ },
245
+ parent_header: { msg_id: msg.header.msg_id },
246
+ metadata: {},
247
+ content: { status: "ok", execution_count: 2 },
248
+ };
249
+ const status: JupyterMessage = {
250
+ channel: "iopub",
251
+ header: {
252
+ msg_id: "status-2",
253
+ session: "session",
254
+ username: "omp",
255
+ date: new Date().toISOString(),
256
+ msg_type: "status",
257
+ version: "5.5",
258
+ },
259
+ parent_header: { msg_id: msg.header.msg_id },
260
+ metadata: {},
261
+ content: { execution_state: "idle" },
262
+ };
263
+ ws.onmessage?.({ data: encodeMessage(stream) });
264
+ ws.onmessage?.({ data: encodeMessage(display) });
265
+ ws.onmessage?.({ data: encodeMessage(reply) });
266
+ ws.onmessage?.({ data: encodeMessage(status) });
267
+ return;
268
+ }
269
+
270
+ sendOkExecution(ws, msg.header.msg_id);
264
271
  });
265
272
 
266
273
  const kernel = await kernelPromise;
@@ -300,40 +307,7 @@ describe("PythonKernel (external gateway)", () => {
300
307
  });
301
308
  globalThis.fetch = fetchMock as unknown as typeof fetch;
302
309
 
303
- const responseQueue: Array<(msgId: string, ws: FakeWebSocket) => void> = [
304
- (msgId, ws) => {
305
- const reply: JupyterMessage = {
306
- channel: "shell",
307
- header: {
308
- msg_id: "reply-prelude",
309
- session: "session",
310
- username: "omp",
311
- date: new Date().toISOString(),
312
- msg_type: "execute_reply",
313
- version: "5.5",
314
- },
315
- parent_header: { msg_id: msgId },
316
- metadata: {},
317
- content: { status: "ok", execution_count: 1 },
318
- };
319
- const status: JupyterMessage = {
320
- channel: "iopub",
321
- header: {
322
- msg_id: "status-prelude",
323
- session: "session",
324
- username: "omp",
325
- date: new Date().toISOString(),
326
- msg_type: "status",
327
- version: "5.5",
328
- },
329
- parent_header: { msg_id: msgId },
330
- metadata: {},
331
- content: { execution_state: "idle" },
332
- };
333
- ws.onmessage?.({ data: encodeMessage(reply) });
334
- ws.onmessage?.({ data: encodeMessage(status) });
335
- },
336
- ];
310
+ let preludeSeen = false;
337
311
 
338
312
  const kernelPromise = PythonKernel.start({ cwd: "/" });
339
313
  await Bun.sleep(10);
@@ -341,11 +315,12 @@ describe("PythonKernel (external gateway)", () => {
341
315
  if (!ws) throw new Error("WebSocket not initialized");
342
316
  ws.setSendHandler((data) => {
343
317
  const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
344
- const handler = responseQueue.shift();
345
- if (!handler) {
346
- throw new Error(`Unexpected message: ${msg.header.msg_type}`);
318
+ const code = String(msg.content.code ?? "");
319
+ if (!preludeSeen) {
320
+ expect(code).toBe(PYTHON_PRELUDE);
321
+ preludeSeen = true;
347
322
  }
348
- handler(msg.header.msg_id, ws);
323
+ sendOkExecution(ws, msg.header.msg_id);
349
324
  });
350
325
 
351
326
  const kernel = await kernelPromise;
@@ -368,40 +343,7 @@ describe("PythonKernel (external gateway)", () => {
368
343
  });
369
344
  globalThis.fetch = fetchMock as unknown as typeof fetch;
370
345
 
371
- const responseQueue: Array<(msgId: string, ws: FakeWebSocket) => void> = [
372
- (msgId, ws) => {
373
- const reply: JupyterMessage = {
374
- channel: "shell",
375
- header: {
376
- msg_id: "reply-prelude",
377
- session: "session",
378
- username: "omp",
379
- date: new Date().toISOString(),
380
- msg_type: "execute_reply",
381
- version: "5.5",
382
- },
383
- parent_header: { msg_id: msgId },
384
- metadata: {},
385
- content: { status: "ok", execution_count: 1 },
386
- };
387
- const status: JupyterMessage = {
388
- channel: "iopub",
389
- header: {
390
- msg_id: "status-prelude",
391
- session: "session",
392
- username: "omp",
393
- date: new Date().toISOString(),
394
- msg_type: "status",
395
- version: "5.5",
396
- },
397
- parent_header: { msg_id: msgId },
398
- metadata: {},
399
- content: { execution_state: "idle" },
400
- };
401
- ws.onmessage?.({ data: encodeMessage(reply) });
402
- ws.onmessage?.({ data: encodeMessage(status) });
403
- },
404
- ];
346
+ let preludeSeen = false;
405
347
 
406
348
  const kernelPromise = PythonKernel.start({ cwd: "/" });
407
349
  await Bun.sleep(10);
@@ -409,12 +351,12 @@ describe("PythonKernel (external gateway)", () => {
409
351
  if (!ws) throw new Error("WebSocket not initialized");
410
352
  ws.setSendHandler((data) => {
411
353
  const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
412
- const handler = responseQueue.shift();
413
- if (!handler) {
414
- throw new Error(`Unexpected message: ${msg.header.msg_type}`);
354
+ const code = String(msg.content.code ?? "");
355
+ if (!preludeSeen) {
356
+ expect(code).toBe(PYTHON_PRELUDE);
357
+ preludeSeen = true;
415
358
  }
416
- expect(msg.content.code).toBe(PYTHON_PRELUDE);
417
- handler(msg.header.msg_id, ws);
359
+ sendOkExecution(ws, msg.header.msg_id);
418
360
  });
419
361
 
420
362
  const kernel = await kernelPromise;
@@ -441,40 +383,23 @@ describe("PythonKernel (external gateway)", () => {
441
383
  ];
442
384
  const payload = JSON.stringify(docs);
443
385
 
444
- const responseQueue: Array<(msgId: string, ws: FakeWebSocket) => void> = [
445
- (msgId, ws) => {
446
- const reply: JupyterMessage = {
447
- channel: "shell",
448
- header: {
449
- msg_id: "reply-prelude",
450
- session: "session",
451
- username: "omp",
452
- date: new Date().toISOString(),
453
- msg_type: "execute_reply",
454
- version: "5.5",
455
- },
456
- parent_header: { msg_id: msgId },
457
- metadata: {},
458
- content: { status: "ok", execution_count: 1 },
459
- };
460
- const status: JupyterMessage = {
461
- channel: "iopub",
462
- header: {
463
- msg_id: "status-prelude",
464
- session: "session",
465
- username: "omp",
466
- date: new Date().toISOString(),
467
- msg_type: "status",
468
- version: "5.5",
469
- },
470
- parent_header: { msg_id: msgId },
471
- metadata: {},
472
- content: { execution_state: "idle" },
473
- };
474
- ws.onmessage?.({ data: encodeMessage(reply) });
475
- ws.onmessage?.({ data: encodeMessage(status) });
476
- },
477
- (msgId, ws) => {
386
+ let preludeSeen = false;
387
+
388
+ const kernelPromise = PythonKernel.start({ cwd: "/" });
389
+ await Bun.sleep(10);
390
+ const ws = FakeWebSocket.lastInstance;
391
+ if (!ws) throw new Error("WebSocket not initialized");
392
+ ws.setSendHandler((data) => {
393
+ const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
394
+ const code = String(msg.content.code ?? "");
395
+ if (!preludeSeen) {
396
+ expect(code).toBe(PYTHON_PRELUDE);
397
+ preludeSeen = true;
398
+ sendOkExecution(ws, msg.header.msg_id);
399
+ return;
400
+ }
401
+
402
+ if (code.includes("__omp_prelude_docs__")) {
478
403
  const stream: JupyterMessage = {
479
404
  channel: "iopub",
480
405
  header: {
@@ -485,7 +410,7 @@ describe("PythonKernel (external gateway)", () => {
485
410
  msg_type: "stream",
486
411
  version: "5.5",
487
412
  },
488
- parent_header: { msg_id: msgId },
413
+ parent_header: { msg_id: msg.header.msg_id },
489
414
  metadata: {},
490
415
  content: { text: `${payload}\n` },
491
416
  };
@@ -499,7 +424,7 @@ describe("PythonKernel (external gateway)", () => {
499
424
  msg_type: "execute_reply",
500
425
  version: "5.5",
501
426
  },
502
- parent_header: { msg_id: msgId },
427
+ parent_header: { msg_id: msg.header.msg_id },
503
428
  metadata: {},
504
429
  content: { status: "ok", execution_count: 2 },
505
430
  };
@@ -513,30 +438,17 @@ describe("PythonKernel (external gateway)", () => {
513
438
  msg_type: "status",
514
439
  version: "5.5",
515
440
  },
516
- parent_header: { msg_id: msgId },
441
+ parent_header: { msg_id: msg.header.msg_id },
517
442
  metadata: {},
518
443
  content: { execution_state: "idle" },
519
444
  };
520
445
  ws.onmessage?.({ data: encodeMessage(stream) });
521
446
  ws.onmessage?.({ data: encodeMessage(reply) });
522
447
  ws.onmessage?.({ data: encodeMessage(status) });
523
- },
524
- ];
525
-
526
- const kernelPromise = PythonKernel.start({ cwd: "/" });
527
- await Bun.sleep(10);
528
- const ws = FakeWebSocket.lastInstance;
529
- if (!ws) throw new Error("WebSocket not initialized");
530
- ws.setSendHandler((data) => {
531
- const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
532
- const handler = responseQueue.shift();
533
- if (!handler) {
534
- throw new Error(`Unexpected message: ${msg.header.msg_type}`);
448
+ return;
535
449
  }
536
- if (msg.content.code !== PYTHON_PRELUDE) {
537
- expect(String(msg.content.code)).toContain("__omp_prelude_docs__");
538
- }
539
- handler(msg.header.msg_id, ws);
450
+
451
+ sendOkExecution(ws, msg.header.msg_id);
540
452
  });
541
453
 
542
454
  const kernel = await kernelPromise;
@@ -6,6 +6,7 @@ import { getShellConfig, killProcessTree } from "../utils/shell";
6
6
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
7
7
  import { logger } from "./logger";
8
8
  import { acquireSharedGateway, releaseSharedGateway } from "./python-gateway-coordinator";
9
+ import { loadPythonModules } from "./python-modules";
9
10
  import { PYTHON_PRELUDE } from "./python-prelude";
10
11
  import { htmlToBasicMarkdown } from "./tools/web-scrapers/types";
11
12
  import { ScopeSignal } from "./utils";
@@ -515,7 +516,7 @@ export class PythonKernel {
515
516
 
516
517
  const externalConfig = getExternalGatewayConfig();
517
518
  if (externalConfig) {
518
- return PythonKernel.startWithExternalGateway(externalConfig);
519
+ return PythonKernel.startWithExternalGateway(externalConfig, options.cwd);
519
520
  }
520
521
 
521
522
  // Try shared gateway first (unless explicitly disabled)
@@ -535,7 +536,7 @@ export class PythonKernel {
535
536
  return PythonKernel.startWithLocalGateway(options);
536
537
  }
537
538
 
538
- private static async startWithExternalGateway(config: ExternalGatewayConfig): Promise<PythonKernel> {
539
+ private static async startWithExternalGateway(config: ExternalGatewayConfig, cwd: string): Promise<PythonKernel> {
539
540
  const headers: Record<string, string> = { "Content-Type": "application/json" };
540
541
  if (config.token) {
541
542
  headers.Authorization = `token ${config.token}`;
@@ -563,6 +564,7 @@ export class PythonKernel {
563
564
  if (preludeResult.cancelled || preludeResult.status === "error") {
564
565
  throw new Error("Failed to initialize Python kernel prelude");
565
566
  }
567
+ await loadPythonModules(kernel, { cwd });
566
568
  return kernel;
567
569
  } catch (err: unknown) {
568
570
  await kernel.shutdown();
@@ -570,7 +572,7 @@ export class PythonKernel {
570
572
  }
571
573
  }
572
574
 
573
- private static async startWithSharedGateway(gatewayUrl: string, _cwd: string): Promise<PythonKernel> {
575
+ private static async startWithSharedGateway(gatewayUrl: string, cwd: string): Promise<PythonKernel> {
574
576
  const createResponse = await fetch(`${gatewayUrl}/api/kernels`, {
575
577
  method: "POST",
576
578
  headers: { "Content-Type": "application/json" },
@@ -594,6 +596,7 @@ export class PythonKernel {
594
596
  if (preludeResult.cancelled || preludeResult.status === "error") {
595
597
  throw new Error("Failed to initialize Python kernel prelude");
596
598
  }
599
+ await loadPythonModules(kernel, { cwd });
597
600
  return kernel;
598
601
  } catch (err: unknown) {
599
602
  await kernel.shutdown();
@@ -709,6 +712,7 @@ export class PythonKernel {
709
712
  if (preludeResult.cancelled || preludeResult.status === "error") {
710
713
  throw new Error("Failed to initialize Python kernel prelude");
711
714
  }
715
+ await loadPythonModules(kernel, { cwd: options.cwd });
712
716
  return kernel;
713
717
  } catch (err: unknown) {
714
718
  await kernel.shutdown();
@@ -0,0 +1,102 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { discoverPythonModules, loadPythonModules, type PythonModuleExecutor } from "./python-modules";
6
+
7
+ const fixturesDir = resolve(__dirname, "../../test/fixtures/python-modules");
8
+
9
+ const readFixture = (name: string): string => readFileSync(join(fixturesDir, name), "utf-8");
10
+
11
+ const writeModule = (dir: string, name: string, tag: string) => {
12
+ mkdirSync(dir, { recursive: true });
13
+ const base = readFixture(name);
14
+ writeFileSync(join(dir, name), `${base}\n# ${tag}`);
15
+ };
16
+
17
+ const createTempRoot = () => mkdtempSync(join(tmpdir(), "omp-python-modules-"));
18
+
19
+ describe("python modules", () => {
20
+ let tempRoot: string | null = null;
21
+
22
+ afterEach(() => {
23
+ if (tempRoot) {
24
+ rmSync(tempRoot, { recursive: true, force: true });
25
+ }
26
+ tempRoot = null;
27
+ });
28
+
29
+ it("discovers modules with project override and sorted order", async () => {
30
+ tempRoot = createTempRoot();
31
+ const homeDir = join(tempRoot, "home");
32
+ const cwd = join(tempRoot, "project");
33
+
34
+ writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp");
35
+ writeModule(join(homeDir, ".pi", "agent", "modules"), "beta.py", "user-pi");
36
+ writeModule(join(homeDir, ".pi", "agent", "modules"), "delta.py", "user-pi");
37
+
38
+ writeModule(join(cwd, ".omp", "modules"), "alpha.py", "project-omp");
39
+ writeModule(join(cwd, ".omp", "modules"), "beta.py", "project-omp");
40
+ writeModule(join(cwd, ".pi", "modules"), "gamma.py", "project-pi");
41
+
42
+ const modules = await discoverPythonModules({ cwd, homeDir });
43
+ const names = modules.map((module) => basename(module.path));
44
+ expect(names).toEqual(["alpha.py", "beta.py", "delta.py", "gamma.py"]);
45
+ expect(modules.map((module) => ({ name: basename(module.path), source: module.source }))).toEqual([
46
+ { name: "alpha.py", source: "project" },
47
+ { name: "beta.py", source: "project" },
48
+ { name: "delta.py", source: "user" },
49
+ { name: "gamma.py", source: "project" },
50
+ ]);
51
+ expect(modules.find((module) => module.path.endsWith("alpha.py"))?.content).toContain("project-omp");
52
+ expect(modules.find((module) => module.path.endsWith("delta.py"))?.content).toContain("user-pi");
53
+ });
54
+
55
+ it("loads modules in sorted order with silent execution", async () => {
56
+ tempRoot = createTempRoot();
57
+ const homeDir = join(tempRoot, "home");
58
+ const cwd = join(tempRoot, "project");
59
+
60
+ writeModule(join(homeDir, ".omp", "agent", "modules"), "beta.py", "user-omp");
61
+ writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp");
62
+
63
+ const calls: Array<{ name: string; options?: { silent?: boolean; storeHistory?: boolean } }> = [];
64
+ const executor: PythonModuleExecutor = {
65
+ execute: async (code: string, options?: { silent?: boolean; storeHistory?: boolean }) => {
66
+ const name = code.includes("def alpha") ? "alpha" : "beta";
67
+ calls.push({ name, options });
68
+ return { status: "ok", cancelled: false };
69
+ },
70
+ };
71
+
72
+ await loadPythonModules(executor, { cwd, homeDir });
73
+ expect(calls.map((call) => call.name)).toEqual(["alpha", "beta"]);
74
+ for (const call of calls) {
75
+ expect(call.options).toEqual({ silent: true, storeHistory: false });
76
+ }
77
+ });
78
+
79
+ it("fails fast when a module fails to execute", async () => {
80
+ tempRoot = createTempRoot();
81
+ const homeDir = join(tempRoot, "home");
82
+ const cwd = join(tempRoot, "project");
83
+
84
+ writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp");
85
+ writeModule(join(cwd, ".omp", "modules"), "beta.py", "project-omp");
86
+
87
+ const executor: PythonModuleExecutor = {
88
+ execute: async (code: string) => {
89
+ if (code.includes("def beta")) {
90
+ return {
91
+ status: "error",
92
+ cancelled: false,
93
+ error: { name: "Error", value: "boom", traceback: [] },
94
+ };
95
+ }
96
+ return { status: "ok", cancelled: false };
97
+ },
98
+ };
99
+
100
+ await expect(loadPythonModules(executor, { cwd, homeDir })).rejects.toThrow("Failed to load Python module");
101
+ });
102
+ });
@@ -0,0 +1,110 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ export type PythonModuleSource = "user" | "project";
6
+
7
+ export interface PythonModuleEntry {
8
+ path: string;
9
+ content: string;
10
+ source: PythonModuleSource;
11
+ }
12
+
13
+ export interface PythonModuleExecuteResult {
14
+ status: "ok" | "error";
15
+ cancelled: boolean;
16
+ error?: { name: string; value: string; traceback: string[] };
17
+ }
18
+
19
+ export interface PythonModuleExecutor {
20
+ execute: (
21
+ code: string,
22
+ options?: { silent?: boolean; storeHistory?: boolean },
23
+ ) => Promise<PythonModuleExecuteResult>;
24
+ }
25
+
26
+ export interface DiscoverPythonModulesOptions {
27
+ /** Working directory for project-level modules. Default: process.cwd() */
28
+ cwd?: string;
29
+ /** Home directory for user-level modules. Default: os.homedir() */
30
+ homeDir?: string;
31
+ }
32
+
33
+ interface ModuleCandidate {
34
+ name: string;
35
+ path: string;
36
+ source: PythonModuleSource;
37
+ }
38
+
39
+ async function listModuleCandidates(dir: string, source: PythonModuleSource): Promise<ModuleCandidate[]> {
40
+ try {
41
+ const entries = await readdir(dir, { withFileTypes: true });
42
+ return entries
43
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".py"))
44
+ .map((entry) => ({
45
+ name: entry.name,
46
+ path: resolve(dir, entry.name),
47
+ source,
48
+ }));
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ async function readModuleContent(candidate: ModuleCandidate): Promise<PythonModuleEntry> {
55
+ try {
56
+ const content = await Bun.file(candidate.path).text();
57
+ return { path: candidate.path, content, source: candidate.source };
58
+ } catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ throw new Error(`Failed to read Python module ${candidate.path}: ${message}`);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Discover Python prelude extension modules from user and project directories.
66
+ */
67
+ export async function discoverPythonModules(options: DiscoverPythonModulesOptions = {}): Promise<PythonModuleEntry[]> {
68
+ const cwd = options.cwd ?? process.cwd();
69
+ const homeDir = options.homeDir ?? homedir();
70
+
71
+ const userDirs = [join(homeDir, ".omp", "agent", "modules"), join(homeDir, ".pi", "agent", "modules")];
72
+ const projectDirs = [resolve(cwd, ".omp", "modules"), resolve(cwd, ".pi", "modules")];
73
+
74
+ const userCandidates = (await Promise.all(userDirs.map((dir) => listModuleCandidates(dir, "user")))).flat();
75
+ const projectCandidates = (await Promise.all(projectDirs.map((dir) => listModuleCandidates(dir, "project")))).flat();
76
+
77
+ const byName = new Map<string, ModuleCandidate>();
78
+ for (const candidate of userCandidates) {
79
+ if (!byName.has(candidate.name)) {
80
+ byName.set(candidate.name, candidate);
81
+ }
82
+ }
83
+ for (const candidate of projectCandidates) {
84
+ const existing = byName.get(candidate.name);
85
+ if (!existing || existing.source === "user") {
86
+ byName.set(candidate.name, candidate);
87
+ }
88
+ }
89
+
90
+ const sorted = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
91
+ return Promise.all(sorted.map((candidate) => readModuleContent(candidate)));
92
+ }
93
+
94
+ /**
95
+ * Load Python prelude extension modules into an active kernel.
96
+ */
97
+ export async function loadPythonModules(
98
+ executor: PythonModuleExecutor,
99
+ options: DiscoverPythonModulesOptions = {},
100
+ ): Promise<PythonModuleEntry[]> {
101
+ const modules = await discoverPythonModules(options);
102
+ for (const module of modules) {
103
+ const result = await executor.execute(module.content, { silent: true, storeHistory: false });
104
+ if (result.cancelled || result.status === "error") {
105
+ const details = result.error ? `${result.error.name}: ${result.error.value}` : "unknown error";
106
+ throw new Error(`Failed to load Python module ${module.path}: ${details}`);
107
+ }
108
+ }
109
+ return modules;
110
+ }