@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
package/src/config.ts CHANGED
@@ -1,20 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import {
5
- CONFIG_DIR_NAME,
6
- getAgentDir,
7
- getConfigAgentDirName,
8
- getProjectDir,
9
- isEnoent,
10
- logger,
11
- } from "@oh-my-pi/pi-utils";
12
- import type { TSchema } from "@sinclair/typebox";
13
- import { Value } from "@sinclair/typebox/value";
14
- import type { ErrorObject } from "ajv";
15
- import { JSONC, YAML } from "bun";
4
+ import { CONFIG_DIR_NAME, getConfigAgentDirName, getProjectDir } from "@oh-my-pi/pi-utils";
16
5
  import { expandTilde } from "./tools/path-utils";
17
6
 
7
+ export * from "./config/config-file";
8
+
18
9
  const priorityList = [
19
10
  { dir: CONFIG_DIR_NAME, globalAgentDir: getConfigAgentDirName },
20
11
  { dir: ".claude" },
@@ -53,213 +44,6 @@ export function getChangelogPath(): string {
53
44
  return path.resolve(path.join(getPackageDir(), "CHANGELOG.md"));
54
45
  }
55
46
 
56
- // =============================================================================
57
- // User Config Paths (~/.omp/agent/*)
58
- // =============================================================================
59
-
60
- function migrateJsonToYml(jsonPath: string, ymlPath: string) {
61
- try {
62
- if (fs.existsSync(ymlPath)) return;
63
- if (!fs.existsSync(jsonPath)) return;
64
-
65
- const content = fs.readFileSync(jsonPath, "utf-8");
66
- const parsed = JSON.parse(content);
67
- if (!parsed) {
68
- logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath });
69
- return;
70
- }
71
- fs.writeFileSync(ymlPath, YAML.stringify(parsed, null, 2));
72
- } catch (error) {
73
- logger.warn("migrateJsonToYml: migration failed", { error: String(error) });
74
- }
75
- }
76
-
77
- export interface IConfigFile<T> {
78
- readonly id: string;
79
- readonly schema: TSchema;
80
- path?(): string;
81
- load(): T | null;
82
- invalidate?(): void;
83
- }
84
-
85
- export class ConfigError extends Error {
86
- readonly #message: string;
87
- constructor(
88
- public readonly id: string,
89
- public readonly schemaErrors: ErrorObject[] | null | undefined,
90
- public readonly other?: { err: unknown; stage: string },
91
- ) {
92
- let messages: string[] | undefined;
93
- let cause: any | undefined;
94
- let klass: string;
95
-
96
- if (schemaErrors) {
97
- klass = "Schema";
98
- messages = schemaErrors.map(e => `${e.instancePath || "root"}: ${e.message}`);
99
- } else if (other) {
100
- klass = other.stage;
101
- if (other.err instanceof Error) {
102
- messages = [other.err.message];
103
- cause = other.err;
104
- } else {
105
- messages = [String(other.err)];
106
- }
107
- } else {
108
- klass = "Unknown";
109
- }
110
-
111
- const title = `Failed to load config file ${id}, ${klass} error:`;
112
- let message: string;
113
- switch (messages?.length ?? 0) {
114
- case 0:
115
- message = title.slice(0, -1);
116
- break;
117
- case 1:
118
- message = `${title} ${messages![0]}`;
119
- break;
120
- default:
121
- message = `${title}\n${messages!.map(m => ` - ${m}`).join("\n")}`;
122
- break;
123
- }
124
-
125
- super(message, { cause });
126
- this.name = "LoadError";
127
- this.#message = message;
128
- }
129
-
130
- get message(): string {
131
- return this.#message;
132
- }
133
-
134
- toString(): string {
135
- return this.message;
136
- }
137
- }
138
-
139
- export type LoadStatus = "ok" | "error" | "not-found";
140
-
141
- export type LoadResult<T> =
142
- | { value?: null; error: ConfigError; status: "error" }
143
- | { value: T; error?: undefined; status: "ok" }
144
- | { value?: null; error?: unknown; status: "not-found" };
145
-
146
- export class ConfigFile<T> implements IConfigFile<T> {
147
- readonly #basePath: string;
148
- #cache?: LoadResult<T>;
149
- #auxValidate?: (value: T) => void;
150
-
151
- constructor(
152
- readonly id: string,
153
- readonly schema: TSchema,
154
- configPath: string = path.join(getAgentDir(), `${id}.yml`),
155
- ) {
156
- this.#basePath = configPath;
157
- if (configPath.endsWith(".yml")) {
158
- const jsonPath = `${configPath.slice(0, -4)}.json`;
159
- migrateJsonToYml(jsonPath, configPath);
160
- } else if (configPath.endsWith(".yaml")) {
161
- const jsonPath = `${configPath.slice(0, -5)}.json`;
162
- migrateJsonToYml(jsonPath, configPath);
163
- } else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
164
- // JSON configs are still supported without migration.
165
- } else {
166
- throw new Error(`Invalid config file path: ${configPath}`);
167
- }
168
- }
169
-
170
- relocate(path?: string): ConfigFile<T> {
171
- if (!path || path === this.#basePath) return this;
172
- const result = new ConfigFile<T>(this.id, this.schema, path);
173
- result.#auxValidate = this.#auxValidate;
174
- return result;
175
- }
176
-
177
- getMtimeMs(): number | null {
178
- try {
179
- return fs.statSync(this.path()).mtimeMs;
180
- } catch (err) {
181
- if (isEnoent(err)) return null;
182
- throw err;
183
- }
184
- }
185
-
186
- withValidation(name: string, validate: (value: T) => void): this {
187
- const prev = this.#auxValidate;
188
- this.#auxValidate = (value: T) => {
189
- prev?.(value);
190
- try {
191
- validate(value);
192
- } catch (error) {
193
- throw new ConfigError(this.id, undefined, { err: error, stage: `Validate(${name})` });
194
- }
195
- };
196
- return this;
197
- }
198
-
199
- createDefault() {
200
- return Value.Default(this.schema, [], undefined) as T;
201
- }
202
-
203
- #storeCache(result: LoadResult<T>): LoadResult<T> {
204
- this.#cache = result;
205
- return result;
206
- }
207
-
208
- tryLoad(): LoadResult<T> {
209
- if (this.#cache) return this.#cache;
210
-
211
- try {
212
- const content = fs.readFileSync(this.path(), "utf-8").trim();
213
-
214
- let parsed: unknown;
215
- if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
216
- parsed = JSONC.parse(content);
217
- } else if (this.#basePath.endsWith(".yml") || this.#basePath.endsWith(".yaml")) {
218
- parsed = YAML.parse(content);
219
- } else {
220
- throw new Error(`Invalid config file path: ${this.#basePath}`);
221
- }
222
-
223
- if (!Value.Check(this.schema, parsed)) {
224
- const schemaErrors: ErrorObject[] = [];
225
- for (const err of Value.Errors(this.schema, parsed)) {
226
- schemaErrors.push({ instancePath: err.path, message: err.message } as ErrorObject);
227
- if (schemaErrors.length >= 50) break;
228
- }
229
- const error = new ConfigError(this.id, schemaErrors);
230
- logger.warn("Failed to parse config file", { path: this.path(), error });
231
- return this.#storeCache({ error, status: "error" });
232
- }
233
- return this.#storeCache({ value: parsed as T, status: "ok" });
234
- } catch (error) {
235
- if (isEnoent(error)) {
236
- return this.#storeCache({ status: "not-found" });
237
- }
238
- logger.warn("Failed to parse config file", { path: this.path(), error });
239
- return this.#storeCache({
240
- error: new ConfigError(this.id, undefined, { err: error, stage: "Unexpected" }),
241
- status: "error",
242
- });
243
- }
244
- }
245
-
246
- load(): T | null {
247
- return this.tryLoad().value ?? null;
248
- }
249
-
250
- loadOrDefault(): T {
251
- return this.tryLoad().value ?? this.createDefault();
252
- }
253
-
254
- path(): string {
255
- return this.#basePath;
256
- }
257
-
258
- invalidate() {
259
- this.#cache = undefined;
260
- }
261
- }
262
-
263
47
  // =============================================================================
264
48
  // Multi-Config Directory Helpers
265
49
  // =============================================================================
@@ -31,6 +31,7 @@ const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
31
31
  interface ClaudePluginManifest {
32
32
  skills?: string;
33
33
  "slash-commands"?: string;
34
+ commands?: string;
34
35
  }
35
36
 
36
37
  interface ResolvedPluginDir {
@@ -59,24 +60,35 @@ function isWithinPluginRoot(rootPath: string, targetPath: string): boolean {
59
60
 
60
61
  async function resolvePluginDir(
61
62
  root: ClaudePluginRoot,
62
- manifestKey: keyof ClaudePluginManifest,
63
+ manifestKeys: ReadonlyArray<keyof ClaudePluginManifest>,
63
64
  fallback: string,
64
65
  ): Promise<ResolvedPluginDir> {
65
66
  const manifest = await readPluginManifest(root);
66
67
  const fallbackDir = path.join(root.path, fallback);
67
- const configured = manifest?.[manifestKey];
68
- if (typeof configured !== "string" || !configured.trim()) {
68
+
69
+ let configured: string | undefined;
70
+ let matchedKey: keyof ClaudePluginManifest | undefined;
71
+ for (const key of manifestKeys) {
72
+ const val = manifest?.[key];
73
+ if (typeof val === "string" && val.trim()) {
74
+ configured = val.trim();
75
+ matchedKey = key;
76
+ break;
77
+ }
78
+ }
79
+
80
+ if (configured === undefined) {
69
81
  return { dir: fallbackDir };
70
82
  }
71
83
 
72
- const resolved = path.resolve(root.path, configured.trim());
84
+ const resolved = path.resolve(root.path, configured);
73
85
  if (isWithinPluginRoot(root.path, resolved)) {
74
86
  return { dir: resolved };
75
87
  }
76
88
 
77
89
  return {
78
90
  dir: fallbackDir,
79
- warning: `[claude-plugins] Ignoring ${String(manifestKey)} path outside plugin root for ${root.id}: ${configured}`,
91
+ warning: `[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${configured}`,
80
92
  };
81
93
  }
82
94
 
@@ -93,7 +105,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
93
105
 
94
106
  const results = await Promise.all(
95
107
  roots.map(async root => {
96
- const { dir: skillsDir, warning } = await resolvePluginDir(root, "skills", "skills");
108
+ const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
97
109
  const result = await scanSkillsFromDir(ctx, {
98
110
  dir: skillsDir,
99
111
  providerId: PROVIDER_ID,
@@ -128,7 +140,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
128
140
 
129
141
  const results = await Promise.all(
130
142
  roots.map(async root => {
131
- const { dir: commandsDir, warning } = await resolvePluginDir(root, "slash-commands", "commands");
143
+ const { dir: commandsDir, warning } = await resolvePluginDir(root, ["commands", "slash-commands"], "commands");
132
144
  const result = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
133
145
  extensions: ["md"],
134
146
  transform: (name, content, filePath, source) => {
@@ -340,7 +340,13 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
340
340
 
341
341
  function parseHashlineInputPreviewHeader(line: string): string | null {
342
342
  if (!line.startsWith(HL_INPUT_HEADER_PREFIX)) return null;
343
- const body = line.slice(HL_INPUT_HEADER_PREFIX.length).trim();
343
+ // The real parser (`parseHashlineHeaderLine` in `hashline/input.ts`) strips
344
+ // every leading "@" before resolving the path so canonical "@@ PATH" headers
345
+ // (and stray "@ PATH" / "@@@ PATH" runs) all route to the same file. Mirror
346
+ // that here so the renderer doesn't surface a literal "@ " in the title.
347
+ let prefixEnd = 0;
348
+ while (prefixEnd < line.length && line[prefixEnd] === HL_INPUT_HEADER_PREFIX) prefixEnd++;
349
+ const body = line.slice(prefixEnd).trim();
344
350
  const previewPath = normalizeHashlineInputPreviewPath(body);
345
351
  return previewPath.length > 0 ? previewPath : null;
346
352
  }
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
2
2
  import type { ToolSession } from "../../tools";
3
+ import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
3
4
  import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
4
5
 
5
6
  export interface JsExecutorOptions {
@@ -49,6 +50,8 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
49
50
  artifactPath: options.artifactPath,
50
51
  artifactId: options.artifactId,
51
52
  spillThreshold: DEFAULT_MAX_BYTES,
53
+ headBytes: resolveOutputSinkHeadBytes(options.session.settings),
54
+ maxColumns: resolveOutputMaxColumns(options.session.settings),
52
55
  onChunk: chunk => options.onChunk?.(chunk),
53
56
  });
54
57
  const timeoutMs = getExecutionTimeoutMs(options);
@@ -180,7 +180,7 @@ export function rewriteImports(code: string): string {
180
180
  * Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
181
181
  * scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
182
182
  */
183
- export function demoteTopLevelLexicals(code: string): string {
183
+ function demoteTopLevelLexicals(code: string): string {
184
184
  if (!/\b(?:const|let|class)\b/.test(code)) return code;
185
185
 
186
186
  const ast = parseProgram(code);
@@ -248,7 +248,7 @@ function returnFinalExpression(code: string): { source: string; returned: boolea
248
248
  * common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex
249
249
  * before invoking the transpiler.
250
250
  */
251
- export function stripTypeScript(code: string): string {
251
+ function stripTypeScript(code: string): string {
252
252
  if (!LOOKS_LIKE_TS.test(code)) return code;
253
253
  try {
254
254
  return new Bun.Transpiler({ loader: "ts" }).transformSync(code);
@@ -1,6 +1,8 @@
1
1
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
2
+ import { Settings } from "../../config/settings";
2
3
  import { OutputSink } from "../../session/streaming-output";
3
4
  import type { ToolSession } from "../../tools";
5
+ import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
4
6
  import type { JsStatusEvent } from "../js/shared/types";
5
7
  import type { KernelDisplayOutput } from "./display";
6
8
  import {
@@ -815,10 +817,13 @@ async function executeWithKernel(
815
817
  code: string,
816
818
  options: PythonExecutorOptions | undefined,
817
819
  ): Promise<PythonResult> {
820
+ const settings = await Settings.init();
818
821
  const sink = new OutputSink({
819
822
  onChunk: options?.onChunk,
820
823
  artifactPath: options?.artifactPath,
821
824
  artifactId: options?.artifactId,
825
+ headBytes: resolveOutputSinkHeadBytes(settings),
826
+ maxColumns: resolveOutputMaxColumns(settings),
822
827
  });
823
828
  const displayOutputs: KernelDisplayOutput[] = [];
824
829
  const deadlineMs = getExecutionDeadlineMs(options);
@@ -25,9 +25,11 @@ when installed.
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
+ import asyncio
28
29
  import ast
29
30
  import base64
30
31
  import builtins
32
+ import inspect
31
33
  import io
32
34
  import json
33
35
  import os
@@ -120,6 +122,7 @@ class _RunnerState:
120
122
  "__builtins__": builtins,
121
123
  }
122
124
  self.last_install_marker: int = 0
125
+ self.loop: asyncio.AbstractEventLoop | None = None
123
126
 
124
127
 
125
128
  _STATE = _RunnerState()
@@ -688,13 +691,41 @@ _install_builtins(_STATE.user_ns)
688
691
  # ---------------------------------------------------------------------------
689
692
 
690
693
 
694
+ _TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
695
+
696
+
697
+ def _get_event_loop() -> asyncio.AbstractEventLoop:
698
+ loop = _STATE.loop
699
+ if loop is None or loop.is_closed():
700
+ loop = asyncio.new_event_loop()
701
+ asyncio.set_event_loop(loop)
702
+ _STATE.loop = loop
703
+ return loop
704
+
705
+
706
+ def _run_compiled(code, ns: dict, *, want_value: bool) -> Any:
707
+ """Execute a code object, awaiting it if compiled as a coroutine.
708
+
709
+ ``want_value`` is True for the trailing expression — we return ``eval``'s
710
+ result (or the awaited coroutine's value). For statement blocks the
711
+ return is always ``None``.
712
+ """
713
+ if code.co_flags & inspect.CO_COROUTINE:
714
+ coro = eval(code, ns)
715
+ result = _get_event_loop().run_until_complete(coro)
716
+ return result if want_value else None
717
+ if want_value:
718
+ return eval(code, ns)
719
+ exec(code, ns)
720
+ return None
721
+
722
+
691
723
  def _exec_source(source: str, ns: dict) -> None:
692
724
  """Compile + execute ``source``; if the last node is an expression, route
693
- its value through ``__omp_display`` so dataframes/figures render rich."""
694
- try:
695
- module = ast.parse(source, mode="exec")
696
- except SyntaxError:
697
- raise
725
+ its value through ``__omp_display`` so dataframes/figures render rich.
726
+ Top-level ``await`` / ``async for`` / ``async with`` is permitted; the
727
+ cell is driven through the runner's persistent event loop."""
728
+ module = ast.parse(source, mode="exec")
698
729
 
699
730
  if not module.body:
700
731
  return
@@ -704,16 +735,16 @@ def _exec_source(source: str, ns: dict) -> None:
704
735
  body_module = ast.Module(body=module.body[:-1], type_ignores=[])
705
736
  expr_module = ast.Expression(body=last.value)
706
737
  ast.copy_location(expr_module, last)
707
- body_code = compile(body_module, "<cell>", "exec")
708
- expr_code = compile(expr_module, "<cell>", "eval")
709
- exec(body_code, ns)
710
- value = eval(expr_code, ns)
738
+ body_code = compile(body_module, "<cell>", "exec", flags=_TLA_FLAG)
739
+ expr_code = compile(expr_module, "<cell>", "eval", flags=_TLA_FLAG)
740
+ _run_compiled(body_code, ns, want_value=False)
741
+ value = _run_compiled(expr_code, ns, want_value=True)
711
742
  if value is not None:
712
743
  __omp_display(value, kind="result")
713
744
  return
714
745
 
715
- code = compile(module, "<cell>", "exec")
716
- exec(code, ns)
746
+ code = compile(module, "<cell>", "exec", flags=_TLA_FLAG)
747
+ _run_compiled(code, ns, want_value=False)
717
748
 
718
749
 
719
750
  # ---------------------------------------------------------------------------
@@ -151,6 +151,7 @@ export function filterEnv(env: Record<string, string | undefined>): Record<strin
151
151
  */
152
152
  export function resolveVenvPath(cwd: string): string | undefined {
153
153
  if ($env.VIRTUAL_ENV) return $env.VIRTUAL_ENV;
154
+ if ($env.CONDA_PREFIX) return $env.CONDA_PREFIX;
154
155
  const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
155
156
  for (const candidate of candidates) {
156
157
  if (fs.existsSync(candidate)) {
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { TObject, TProperties } from "@sinclair/typebox";
5
5
  import type { CustomTool } from "../extensibility/custom-tools/types";
6
- import { callExaTool, findApiKey, formatSearchResults, isSearchResponse } from "./mcp-client";
6
+ import { callExaTool, findApiKey, formatGenericResponse, formatSearchResults, isSearchResponse } from "./mcp-client";
7
7
  import type { ExaRenderDetails } from "./types";
8
8
 
9
9
  /** Creates an Exa tool with standardized API key handling, error wrapping, and optional search response formatting. */
@@ -44,7 +44,7 @@ export function createExaTool(
44
44
  }
45
45
 
46
46
  return {
47
- content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }],
47
+ content: [{ type: "text" as const, text: formatGenericResponse(response) }],
48
48
  details: { raw: response, toolName: name },
49
49
  };
50
50
  } catch (error) {
@@ -174,6 +174,79 @@ export function formatSearchResults(data: ExaSearchResponse): string {
174
174
 
175
175
  return output.trim();
176
176
  }
177
+ /**
178
+ * Format a non-search MCP response as human-readable text.
179
+ * Handles objects, arrays, primitives, and common MCP response shapes.
180
+ */
181
+ export function formatGenericResponse(data: unknown): string {
182
+ if (data === null || data === undefined) return "No result.";
183
+ if (typeof data === "string") return data;
184
+ if (typeof data === "number" || typeof data === "boolean") return String(data);
185
+
186
+ if (Array.isArray(data)) {
187
+ if (data.length === 0) return "(empty)";
188
+ const parts: string[] = [];
189
+ for (let i = 0; i < data.length; i++) {
190
+ const item = data[i];
191
+ if (typeof item === "object" && item !== null) {
192
+ const record = item as Record<string, unknown>;
193
+ const title = (record.title ?? record.name ?? record.id ?? `Item ${i + 1}`) as string;
194
+ parts.push(`\n### ${title}`);
195
+ for (const [k, v] of Object.entries(record)) {
196
+ if (["title", "name", "id"].includes(k)) continue;
197
+ parts.push(`- **${k}:** ${formatValue(v)}`);
198
+ }
199
+ } else {
200
+ parts.push(`- ${formatValue(item)}`);
201
+ }
202
+ }
203
+ return parts.join("\n");
204
+ }
205
+
206
+ if (typeof data === "object") {
207
+ const record = data as Record<string, unknown>;
208
+ if (record.content && Array.isArray(record.content)) {
209
+ // MCP-style content array — extract text blocks
210
+ const texts = record.content
211
+ .filter(
212
+ (c: unknown): c is { type: string; text?: string } =>
213
+ typeof c === "object" && c !== null && (c as Record<string, unknown>)?.type === "text",
214
+ )
215
+ .map(c => c.text ?? "")
216
+ .filter(Boolean);
217
+ if (texts.length > 0) return texts.join("\n");
218
+ }
219
+
220
+ const lines: string[] = [];
221
+ for (const [k, v] of Object.entries(record)) {
222
+ if (k === "content") continue; // handled above
223
+ if (v === null || v === undefined) continue;
224
+ if (typeof v === "object") {
225
+ const formatted = formatGenericResponse(v);
226
+ if (formatted) lines.push(`- **${k}:**\n${indent(formatted, 2)}`);
227
+ } else {
228
+ lines.push(`- **${k}:** ${formatValue(v)}`);
229
+ }
230
+ }
231
+ return lines.join("\n") || "(empty)";
232
+ }
233
+
234
+ return String(data);
235
+ }
236
+
237
+ function formatValue(v: unknown): string {
238
+ if (v === null || v === undefined) return "—";
239
+ if (typeof v === "object") return JSON.stringify(v);
240
+ return String(v);
241
+ }
242
+
243
+ function indent(text: string, spaces: number): string {
244
+ const pad = " ".repeat(spaces);
245
+ return text
246
+ .split("\n")
247
+ .map(line => pad + line)
248
+ .join("\n");
249
+ }
177
250
 
178
251
  /** Check if result is a search response */
179
252
  export function isSearchResponse(data: unknown): data is ExaSearchResponse {
@@ -260,7 +333,7 @@ export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
260
333
  }
261
334
 
262
335
  return {
263
- content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }],
336
+ content: [{ type: "text" as const, text: formatGenericResponse(response) }],
264
337
  details: { raw: response, toolName: this.config.name },
265
338
  };
266
339
  } catch (error) {
@@ -7,6 +7,7 @@ import * as fs from "node:fs/promises";
7
7
  import { executeShell, type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives";
8
8
  import { Settings, type ShellMinimizerSettings } from "../config/settings";
9
9
  import { OutputSink } from "../session/streaming-output";
10
+ import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
10
11
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
11
12
  import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
12
13
 
@@ -64,7 +65,8 @@ async function resolveShellCwd(cwd: string | undefined): Promise<string | undefi
64
65
  }
65
66
  }
66
67
 
67
- function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
68
+ /** Translate `ShellMinimizerSettings` into native `MinimizerOptions`, or `undefined` when disabled. */
69
+ export function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
68
70
  if (!group.enabled) return undefined;
69
71
  return {
70
72
  enabled: true,
@@ -94,6 +96,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
94
96
  onChunk: options?.onChunk,
95
97
  artifactPath: options?.artifactPath,
96
98
  artifactId: options?.artifactId,
99
+ headBytes: resolveOutputSinkHeadBytes(settings),
100
+ maxColumns: resolveOutputMaxColumns(settings),
97
101
  // Throttle the streaming preview callback to avoid saturating the
98
102
  // event loop when commands produce massive output (e.g. seq 1 50M).
99
103
  chunkThrottleMs: options?.onChunk ? 50 : 0,