@oh-my-pi/pi-coding-agent 6.9.69 → 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.
@@ -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) {
@@ -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]] = []
@@ -87,10 +87,22 @@ export async function runFd(fdPath: string, args: string[], signal?: AbortSignal
87
87
  throw err;
88
88
  }
89
89
 
90
+ let exitError: unknown;
91
+ try {
92
+ await child.exited;
93
+ } catch (err) {
94
+ exitError = err;
95
+ if (err instanceof ptree.Exception && err.aborted) {
96
+ throw new Error("Operation aborted");
97
+ }
98
+ }
99
+
100
+ const exitCode = child.exitCode ?? (exitError instanceof ptree.Exception ? exitError.exitCode : null);
101
+
90
102
  return {
91
103
  stdout,
92
104
  stderr: child.peekStderr(),
93
- exitCode: child.exitCode,
105
+ exitCode,
94
106
  };
95
107
  }
96
108
 
@@ -271,11 +283,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
271
283
  const { stdout, stderr, exitCode } = await runFd(fdPath, args, signal);
272
284
  const output = stdout.trim();
273
285
 
274
- if (exitCode !== 0 && !output) {
275
- throw new Error(stderr.trim() || `fd exited with code ${exitCode ?? -1}`);
276
- }
277
-
286
+ // fd exit codes: 0 = found files, 1 = no matches, other = error
287
+ // Treat exit code 1 with no output as "no files found"
278
288
  if (!output) {
289
+ if (exitCode !== 0 && exitCode !== 1) {
290
+ throw new Error(stderr.trim() || `fd failed (exit ${exitCode})`);
291
+ }
279
292
  return {
280
293
  content: [{ type: "text", text: "No files found matching pattern" }],
281
294
  details: { scopePath, fileCount: 0, files: [], truncated: false },
@@ -243,6 +243,14 @@ function getLspServerForFile(config: LspConfig, filePath: string): [string, Serv
243
243
 
244
244
  const FILE_SEARCH_MAX_DEPTH = 5;
245
245
  const IGNORED_DIRS = new Set(["node_modules", "target", "dist", "build", ".git"]);
246
+ const DIAGNOSTIC_MESSAGE_LIMIT = 50;
247
+
248
+ function limitDiagnosticMessages(messages: string[]): string[] {
249
+ if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
250
+ return messages;
251
+ }
252
+ return messages.slice(0, DIAGNOSTIC_MESSAGE_LIMIT);
253
+ }
246
254
 
247
255
  function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: number): string | null {
248
256
  const normalized = extensions.map((ext) => ext.toLowerCase());
@@ -563,12 +571,13 @@ async function getDiagnosticsForFile(
563
571
  }
564
572
 
565
573
  const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
574
+ const limited = limitDiagnosticMessages(formatted);
566
575
  const summary = formatDiagnosticsSummary(uniqueDiagnostics);
567
576
  const hasErrors = uniqueDiagnostics.some((d) => d.severity === 1);
568
577
 
569
578
  return {
570
579
  server: serverNames.join(", "),
571
- messages: formatted,
580
+ messages: limited,
572
581
  summary,
573
582
  errored: hasErrors,
574
583
  };
@@ -788,16 +797,18 @@ function mergeDiagnostics(
788
797
 
789
798
  let summary = options.enableDiagnostics ? "no issues" : "OK";
790
799
  let errored = false;
800
+ let limitedMessages = messages;
791
801
  if (messages.length > 0) {
792
802
  const summaryInfo = summarizeDiagnosticMessages(messages);
793
803
  summary = summaryInfo.summary;
794
804
  errored = summaryInfo.errored;
805
+ limitedMessages = limitDiagnosticMessages(messages);
795
806
  }
796
807
  const formatter = hasFormatter ? (formatted ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED) : undefined;
797
808
 
798
809
  return {
799
810
  server: servers.size > 0 ? Array.from(servers).join(", ") : undefined,
800
- messages,
811
+ messages: limitedMessages,
801
812
  summary,
802
813
  errored,
803
814
  formatter,
@@ -269,6 +269,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
269
269
  sessionId,
270
270
  kernelMode: this.session.settings?.getPythonKernelMode?.() ?? "session",
271
271
  useSharedGateway: this.session.settings?.getPythonSharedGateway?.() ?? true,
272
+ sessionFile: sessionFile ?? undefined,
272
273
  };
273
274
 
274
275
  for (let i = 0; i < cells.length; i++) {
@@ -223,11 +223,11 @@ async function listCandidateFiles(
223
223
  const { stdout, stderr, exitCode } = await runFd(fdPath, args, signal);
224
224
  const output = stdout.trim();
225
225
 
226
- if (exitCode !== 0 && !output) {
227
- return { files: [], truncated: false, error: stderr.trim() || `fd exited with code ${exitCode ?? -1}` };
228
- }
229
-
230
226
  if (!output) {
227
+ // fd exit codes: 0 = found, 1 = no matches, other = error
228
+ if (exitCode !== 0 && exitCode !== 1) {
229
+ return { files: [], truncated: false, error: stderr.trim() || `fd failed (exit ${exitCode})` };
230
+ }
231
231
  return { files: [], truncated: false };
232
232
  }
233
233