@oh-my-pi/pi-coding-agent 14.9.3 → 14.9.5

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 (79) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/package.json +7 -7
  3. package/src/async/job-manager.ts +66 -9
  4. package/src/capability/rule.ts +20 -0
  5. package/src/config/model-registry.ts +13 -0
  6. package/src/config/model-resolver.ts +8 -2
  7. package/src/config/settings-schema.ts +1 -1
  8. package/src/edit/index.ts +8 -0
  9. package/src/edit/renderer.ts +6 -1
  10. package/src/edit/streaming.ts +53 -2
  11. package/src/eval/js/context-manager.ts +1 -38
  12. package/src/eval/js/prelude.txt +0 -2
  13. package/src/eval/py/executor.ts +24 -8
  14. package/src/eval/py/index.ts +1 -0
  15. package/src/eval/py/prelude.py +11 -80
  16. package/src/export/html/template.css +12 -0
  17. package/src/export/html/template.generated.ts +1 -1
  18. package/src/export/html/template.js +20 -2
  19. package/src/extensibility/plugins/loader.ts +31 -6
  20. package/src/extensibility/skills.ts +20 -0
  21. package/src/internal-urls/agent-protocol.ts +63 -52
  22. package/src/internal-urls/artifact-protocol.ts +51 -51
  23. package/src/internal-urls/docs-index.generated.ts +33 -1
  24. package/src/internal-urls/index.ts +6 -19
  25. package/src/internal-urls/local-protocol.ts +49 -7
  26. package/src/internal-urls/mcp-protocol.ts +2 -8
  27. package/src/internal-urls/memory-protocol.ts +89 -59
  28. package/src/internal-urls/router.ts +38 -22
  29. package/src/internal-urls/rule-protocol.ts +2 -20
  30. package/src/internal-urls/skill-protocol.ts +4 -27
  31. package/src/main.ts +1 -1
  32. package/src/mcp/manager.ts +17 -0
  33. package/src/modes/components/session-observer-overlay.ts +2 -2
  34. package/src/modes/components/tool-execution.ts +6 -0
  35. package/src/modes/components/tree-selector.ts +4 -0
  36. package/src/modes/controllers/event-controller.ts +23 -2
  37. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  38. package/src/modes/interactive-mode.ts +2 -2
  39. package/src/modes/theme/theme.ts +27 -27
  40. package/src/modes/types.ts +1 -1
  41. package/src/modes/utils/ui-helpers.ts +14 -9
  42. package/src/prompts/commands/orchestrate.md +1 -0
  43. package/src/prompts/system/project-prompt.md +10 -2
  44. package/src/prompts/system/subagent-system-prompt.md +8 -8
  45. package/src/prompts/system/system-prompt.md +13 -7
  46. package/src/prompts/tools/ask.md +0 -1
  47. package/src/prompts/tools/bash.md +0 -10
  48. package/src/prompts/tools/eval.md +1 -3
  49. package/src/prompts/tools/github.md +6 -5
  50. package/src/prompts/tools/hashline.md +1 -0
  51. package/src/prompts/tools/job.md +14 -6
  52. package/src/prompts/tools/task.md +20 -3
  53. package/src/registry/agent-registry.ts +2 -1
  54. package/src/sdk.ts +87 -89
  55. package/src/session/agent-session.ts +58 -20
  56. package/src/session/artifacts.ts +7 -4
  57. package/src/session/session-manager.ts +30 -1
  58. package/src/ssh/connection-manager.ts +32 -16
  59. package/src/ssh/sshfs-mount.ts +10 -7
  60. package/src/system-prompt.ts +0 -5
  61. package/src/task/executor.ts +14 -2
  62. package/src/task/index.ts +19 -5
  63. package/src/tool-discovery/tool-index.ts +21 -8
  64. package/src/tools/ast-edit.ts +3 -2
  65. package/src/tools/ast-grep.ts +3 -2
  66. package/src/tools/bash.ts +15 -9
  67. package/src/tools/browser/tab-supervisor.ts +12 -2
  68. package/src/tools/eval.ts +48 -10
  69. package/src/tools/fetch.ts +1 -1
  70. package/src/tools/gh.ts +140 -4
  71. package/src/tools/index.ts +12 -11
  72. package/src/tools/job.ts +48 -12
  73. package/src/tools/read.ts +5 -4
  74. package/src/tools/search.ts +3 -2
  75. package/src/tools/todo-write.ts +1 -1
  76. package/src/web/scrapers/mastodon.ts +1 -1
  77. package/src/web/scrapers/repology.ts +7 -7
  78. package/src/internal-urls/jobs-protocol.ts +0 -120
  79. package/src/prompts/system/now-prompt.md +0 -7
package/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.9.5] - 2026-05-12
6
+ ### Breaking Changes
7
+
8
+ - Removed the `jobs://` internal URL protocol; inspect background jobs via the `job` tool's `list: true` operation instead
9
+
10
+ ### Added
11
+
12
+ - Added `since` and `until` date-range filters to `search_issues`, `search_prs`, `search_commits`, and `search_repos`, accepting relative durations (`m`/`h`/`d`/`w`/`mo`/`y`), ISO dates, and ISO datetimes
13
+ - Added `dateField` support for date filtering (`created` or `updated`) so search results can be constrained by creation, update, pushed (for repos), or committer date (for commits)
14
+ - Added owner-based scoping to async job registration and queries so background jobs can be registered with an `ownerId` and filtered per agent in `getRunningJobs`, `getRecentJobs`, `getAllJobs`, and `cancelAll`
15
+ - Added agent ownership metadata to async jobs started by `task` and `bash` tools so their lifecycle and cancellation is attributed to the creating agent
16
+ - Added `list: true` operation to the `job` tool, returning an immediate snapshot of every job spawned by the calling agent without waiting (replaces the deleted `jobs://` URL)
17
+ - Added per-agent visibility scoping to the `job` tool so `list`, `poll`, and `cancel` only see and act on jobs owned by the calling agent; cross-agent operations now return `not_found`
18
+
19
+ ### Changed
20
+
21
+ - Changed `search_issues`, `search_prs`, `search_commits`, and `search_repos` to allow date-only queries where `query` is omitted if `since`/`until` is provided
22
+ - Changed `search_code` to return a validation error when `since`/`until` is supplied because GitHub code search does not support date qualifiers
23
+ - Changed async job manager ownership so subagents inherit the parent session’s global `AsyncJobManager` instead of creating and owning separate instances
24
+ - Changed session lifecycle cleanup so the global async-job manager is disposed only by the owning top-level session
25
+ - Changed subagent session switches and handoff paths to stop global async-job cancellation and cancel only jobs owned by that session
26
+ - Changed `agent://` and `artifact://` URL resolution to search artifact outputs across all active sessions instead of only the current session, allowing parent and subagent sessions to read each other’s generated outputs by ID
27
+ - Changed `memory://` URL resolution to walk all active sessions’ memory roots and return the first matching file, so worktree-based subagents can access their own memory views as well as shared roots
28
+ - Changed internal URL routing to use a shared process-global `InternalUrlRouter` and protocol handlers, so built-in tools resolve `agent://`, `artifact://`, `memory://`, `skill://`, `rule://`, `mcp://`, and `local://` URLs without requiring session-specific router wiring
29
+ - Changed `mcp://` handler to use the globally registered MCP manager so MCP resource links work for agents sharing session context
30
+
31
+ ### Changed
32
+
33
+ - Changed the `ask.timeout` default from `30` (seconds) to `0` (wait indefinitely). Auto-selecting the recommended option after a fixed delay was surprising users mid-deliberation; the timer is now strictly opt-in. The legacy auto-select behavior is preserved when `ask.timeout` is set to a non-zero value, and the `ask` tool's prompt has been updated so the model expects unlimited reply time by default.
34
+
35
+ ### Fixed
36
+
37
+ - Added `ModelRegistry.hasConfiguredAuth(model)` to mirror the upstream `@mariozechner/pi-coding-agent` API surface; external plugins and downstream wrappers that pre-flight auth before launching a subagent no longer crash with `this._modelRegistry.hasConfiguredAuth is not a function` on the direct agent-launch path. ([#993](https://github.com/can1357/oh-my-pi/issues/993))
38
+ - Fixed an ESM circular-import TDZ that crashed test suites when modules from the `task/` and `tools/` graphs were evaluated together (e.g. `executor-warnings.test.ts` + `task-simple-mode.test.ts`) by deferring `BUILTIN_TOOLS.task`'s `TaskTool.create` dereference to factory-call time and sourcing `truncateTail` from `session/streaming-output` instead of the `tools/` barrel
39
+ - Treat keyless-by-design providers (llama.cpp, ollama, lm-studio) as authenticated in subagent model resolution; fixes silent fallback to parent remote model when a local model is configured. ([#1008](https://github.com/can1357/oh-my-pi/issues/1008))
40
+ - Fixed subagent disposal and session transitions that previously canceled all running async jobs, preventing inadvertent termination of a parent agent’s background work
41
+ - Fixed multi-entry edits silently rendering a fake success when every entry failed (e.g. all hit the auto-generated guard), by surfacing `isError: true` from the single-path edit orchestrator so the renderer takes the error branch instead of falling through to the streaming-preview fallback that displays the *proposed* diff
42
+ - Fixed the auto-generated streaming guard being gated behind `edit.streamingAbort` (default false), so it now pre-empts streaming edit tool calls targeting auto-generated files regardless of that setting
43
+ - Fixed subagents launched in the same parallel batch not seeing each other in their initial `# IRC Peers` system-prompt block by pre-registering the agent in the global `AgentRegistry` before `rebuildSystemPrompt` runs and attaching the live session afterwards
44
+ - Fixed plugin manifest extensions whose entry points at a directory (e.g. `pi-goal`'s `"pi": { "extensions": [".pi/extensions/pi-goal"] }`) failing to load with `Failed to load extension: Directories cannot be read like files`. The plugin path resolver now resolves directory entries to their `index.{ts,js,mjs,cjs}` file, matching the behavior of native auto-discovery via `resolveExtensionEntries`.
45
+ - Fixed the SSH tool on native Windows by avoiding OpenSSH ControlMaster multiplexing, which Win32-OpenSSH does not support and reports as `getsockname failed` ([#154](https://github.com/can1357/oh-my-pi/issues/154)).
46
+ - Fixed `/export` and `/tree` not showing developer-role messages (including the plan content injected after `/plan` approval) so the HTML export and TUI session tree now render developer messages dimmed with their actual content instead of hiding them entirely ([#753](https://github.com/can1357/oh-my-pi/issues/753))
47
+ - Fixed `Timed out initializing browser tab worker` on prebuilt binaries by rewriting `spawnTabWorker` to import the worker entry with `with { type: "file" }` so Bun's `--compile` bundler statically discovers and embeds `tab-worker-entry.ts` in the single-file binary ([#1011](https://github.com/can1357/oh-my-pi/issues/1011))
48
+
5
49
  ## [14.9.3] - 2026-05-10
6
50
  ### Breaking Changes
7
51
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.9.3",
4
+ "version": "14.9.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "14.9.3",
51
- "@oh-my-pi/pi-agent-core": "14.9.3",
52
- "@oh-my-pi/pi-ai": "14.9.3",
53
- "@oh-my-pi/pi-natives": "14.9.3",
54
- "@oh-my-pi/pi-tui": "14.9.3",
55
- "@oh-my-pi/pi-utils": "14.9.3",
50
+ "@oh-my-pi/omp-stats": "14.9.5",
51
+ "@oh-my-pi/pi-agent-core": "14.9.5",
52
+ "@oh-my-pi/pi-ai": "14.9.5",
53
+ "@oh-my-pi/pi-natives": "14.9.5",
54
+ "@oh-my-pi/pi-tui": "14.9.5",
55
+ "@oh-my-pi/pi-utils": "14.9.5",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@sinclair/typebox": "^0.34.49",
58
58
  "@types/turndown": "5.0.6",
@@ -16,6 +16,13 @@ export interface AsyncJob {
16
16
  promise: Promise<void>;
17
17
  resultText?: string;
18
18
  errorText?: string;
19
+ /**
20
+ * Registry id of the agent that registered the job (e.g. "0-Main",
21
+ * "3-AuthLoader"). Used by scoped cancel/list APIs so a subagent's teardown
22
+ * does not cancel its parent's jobs. Undefined for callers that don't
23
+ * supply an id (e.g. legacy tests, SDK consumers without an agent context).
24
+ */
25
+ ownerId?: string;
19
26
  }
20
27
 
21
28
  export interface AsyncJobManagerOptions {
@@ -41,10 +48,38 @@ export interface AsyncJobDeliveryState {
41
48
 
42
49
  export interface AsyncJobRegisterOptions {
43
50
  id?: string;
51
+ /** Registry id of the agent that owns this job; used to scope cancelAll. */
52
+ ownerId?: string;
44
53
  onProgress?: (text: string, details?: Record<string, unknown>) => void | Promise<void>;
45
54
  }
46
55
 
56
+ /**
57
+ * Filter applied to job query/cancel APIs. With `ownerId`, results are
58
+ * restricted to jobs registered by that agent (registry id from
59
+ * `AgentRegistry`, e.g. "0-Main", "3-AuthLoader").
60
+ */
61
+ export interface AsyncJobFilter {
62
+ ownerId?: string;
63
+ }
64
+
47
65
  export class AsyncJobManager {
66
+ static #instance: AsyncJobManager | undefined;
67
+
68
+ /** Process-global instance shared by internal URL protocol handlers and tools. */
69
+ static instance(): AsyncJobManager | undefined {
70
+ return AsyncJobManager.#instance;
71
+ }
72
+
73
+ /** Install or clear the process-global instance. */
74
+ static setInstance(value: AsyncJobManager | undefined): void {
75
+ AsyncJobManager.#instance = value;
76
+ }
77
+
78
+ /** Reset the process-global instance. Test-only. */
79
+ static resetForTests(): void {
80
+ AsyncJobManager.#instance = undefined;
81
+ }
82
+
48
83
  readonly #jobs = new Map<string, AsyncJob>();
49
84
  readonly #deliveries: AsyncJobDelivery[] = [];
50
85
  readonly #suppressedDeliveries = new Set<string>();
@@ -56,6 +91,16 @@ export class AsyncJobManager {
56
91
  #deliveryLoop: Promise<void> | undefined;
57
92
  #disposed = false;
58
93
 
94
+ #filterJobs(jobs: Iterable<AsyncJob>, filter?: AsyncJobFilter): AsyncJob[] {
95
+ const ownerId = filter?.ownerId;
96
+ if (!ownerId) return Array.from(jobs);
97
+ const out: AsyncJob[] = [];
98
+ for (const job of jobs) {
99
+ if (job.ownerId === ownerId) out.push(job);
100
+ }
101
+ return out;
102
+ }
103
+
59
104
  constructor(options: AsyncJobManagerOptions) {
60
105
  this.#onJobComplete = options.onJobComplete;
61
106
  this.#maxRunningJobs = Math.max(1, Math.floor(options.maxRunningJobs ?? DEFAULT_MAX_RUNNING_JOBS));
@@ -95,6 +140,7 @@ export class AsyncJobManager {
95
140
  label,
96
141
  abortController,
97
142
  promise: Promise.resolve(),
143
+ ownerId: options?.ownerId,
98
144
  };
99
145
 
100
146
  const reportProgress = async (text: string, details?: Record<string, unknown>): Promise<void> => {
@@ -138,9 +184,15 @@ export class AsyncJobManager {
138
184
  return id;
139
185
  }
140
186
 
141
- cancel(id: string): boolean {
187
+ /**
188
+ * Cancel a single job by id. When `filter.ownerId` is set and does not
189
+ * match the job's owner, the call is treated as not-found (returns false)
190
+ * so cross-agent cancellation is rejected at the manager level.
191
+ */
192
+ cancel(id: string, filter?: AsyncJobFilter): boolean {
142
193
  const job = this.#jobs.get(id);
143
194
  if (!job) return false;
195
+ if (filter?.ownerId && job.ownerId !== filter.ownerId) return false;
144
196
  if (job.status !== "running") return false;
145
197
  job.status = "cancelled";
146
198
  job.abortController.abort();
@@ -152,19 +204,19 @@ export class AsyncJobManager {
152
204
  return this.#jobs.get(id);
153
205
  }
154
206
 
155
- getRunningJobs(): AsyncJob[] {
156
- return Array.from(this.#jobs.values()).filter(job => job.status === "running");
207
+ getRunningJobs(filter?: AsyncJobFilter): AsyncJob[] {
208
+ return this.#filterJobs(this.#jobs.values(), filter).filter(job => job.status === "running");
157
209
  }
158
210
 
159
- getRecentJobs(limit = 10): AsyncJob[] {
160
- return Array.from(this.#jobs.values())
211
+ getRecentJobs(limit = 10, filter?: AsyncJobFilter): AsyncJob[] {
212
+ return this.#filterJobs(this.#jobs.values(), filter)
161
213
  .filter(job => job.status !== "running")
162
214
  .sort((a, b) => b.startTime - a.startTime)
163
215
  .slice(0, limit);
164
216
  }
165
217
 
166
- getAllJobs(): AsyncJob[] {
167
- return Array.from(this.#jobs.values());
218
+ getAllJobs(filter?: AsyncJobFilter): AsyncJob[] {
219
+ return this.#filterJobs(this.#jobs.values(), filter);
168
220
  }
169
221
 
170
222
  getDeliveryState(): AsyncJobDeliveryState {
@@ -221,8 +273,13 @@ export class AsyncJobManager {
221
273
  return before - this.#deliveries.length;
222
274
  }
223
275
 
224
- cancelAll(): void {
225
- for (const job of this.getRunningJobs()) {
276
+ /**
277
+ * Cancel running jobs. With `filter.ownerId` set, cancels only jobs the
278
+ * matching agent registered; with no filter, cancels every running job
279
+ * (used by `dispose()` to nuke the manager's state).
280
+ */
281
+ cancelAll(filter?: AsyncJobFilter): void {
282
+ for (const job of this.getRunningJobs(filter)) {
226
283
  job.status = "cancelled";
227
284
  job.abortController.abort();
228
285
  this.#scheduleEviction(job.id);
@@ -209,6 +209,26 @@ export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick<R
209
209
  };
210
210
  }
211
211
 
212
+ let activeRules: readonly Rule[] = [];
213
+
214
+ /**
215
+ * Process-global snapshot of rules the active session loaded.
216
+ * Read by internal URL protocol handlers (rule://).
217
+ */
218
+ export function getActiveRules(): readonly Rule[] {
219
+ return activeRules;
220
+ }
221
+
222
+ /** Replace the active rule snapshot. Called once per top-level session. */
223
+ export function setActiveRules(value: readonly Rule[]): void {
224
+ activeRules = value;
225
+ }
226
+
227
+ /** Reset the active rule snapshot. Test-only. */
228
+ export function resetActiveRulesForTests(): void {
229
+ activeRules = [];
230
+ }
231
+
212
232
  export const ruleCapability = defineCapability<Rule>({
213
233
  id: "rules",
214
234
  displayName: "Rules",
@@ -2072,6 +2072,19 @@ export class ModelRegistry {
2072
2072
  return this.#models.filter(model => this.#isModelAvailable(model));
2073
2073
  }
2074
2074
 
2075
+ /**
2076
+ * Check whether auth is configured for a model's provider.
2077
+ *
2078
+ * Mirrors the upstream `@mariozechner/pi-coding-agent` API surface so that
2079
+ * external plugins/extensions and downstream wrappers (e.g. subagent launch
2080
+ * paths that pre-flight auth before model resolution) can probe a model
2081
+ * without resolving an API key. Returns true for keyless providers as well
2082
+ * as providers with stored credentials. See issue #993.
2083
+ */
2084
+ hasConfiguredAuth(model: Model<Api>): boolean {
2085
+ return this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider);
2086
+ }
2087
+
2075
2088
  getDiscoverableProviders(): string[] {
2076
2089
  const disabledProviders = getDisabledProviderIdsFromSettings();
2077
2090
  return this.#discoverableProviders
@@ -16,7 +16,7 @@ import chalk from "chalk";
16
16
  import MODEL_PRIO from "../priority.json" with { type: "json" };
17
17
  import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
18
18
  import { fuzzyMatch } from "../utils/fuzzy";
19
- import { isAuthenticated, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
19
+ import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
20
20
  import type { Settings } from "./settings";
21
21
 
22
22
  /** Default model IDs for each known provider */
@@ -743,6 +743,12 @@ export function resolveModelOverride(
743
743
  * `modelRoles.task` pointing at an unqualified id whose only available
744
744
  * provider variant has no configured credentials — see #985).
745
745
  *
746
+ * Keyless-by-design providers (llama.cpp, ollama, lm-studio) advertise the
747
+ * `kNoAuth` sentinel from `getApiKey` to signal that they do not require
748
+ * credentials. Those are treated as authenticated here so an explicitly
749
+ * configured local model is never silently rerouted to the parent's remote
750
+ * provider (see #1008).
751
+ *
746
752
  * If neither the subagent nor the parent has working auth, returns the
747
753
  * primary resolution unchanged so the existing error path still surfaces
748
754
  * a meaningful failure downstream.
@@ -764,7 +770,7 @@ export async function resolveModelOverrideWithAuthFallback(
764
770
  }
765
771
 
766
772
  const primaryKey = await modelRegistry.getApiKey(primary.model);
767
- if (isAuthenticated(primaryKey)) {
773
+ if (primaryKey === kNoAuth || isAuthenticated(primaryKey)) {
768
774
  return { ...primary, authFallbackUsed: false };
769
775
  }
770
776
 
@@ -905,7 +905,7 @@ export const SETTINGS_SCHEMA = {
905
905
 
906
906
  "ask.timeout": {
907
907
  type: "number",
908
- default: 30,
908
+ default: 0,
909
909
  ui: {
910
910
  tab: "interaction",
911
911
  label: "Ask Timeout",
package/src/edit/index.ts CHANGED
@@ -204,6 +204,7 @@ async function executeSinglePathEntries(
204
204
  const contentTexts: string[] = [];
205
205
  const diffTexts: string[] = [];
206
206
  let firstChangedLine: number | undefined;
207
+ let errorCount = 0;
207
208
 
208
209
  for (let i = 0; i < runs.length; i++) {
209
210
  const isLast = i === runs.length - 1;
@@ -221,6 +222,7 @@ async function executeSinglePathEntries(
221
222
  } catch (err) {
222
223
  const errorText = err instanceof Error ? err.message : String(err);
223
224
  contentTexts.push(`Error editing ${path}: ${errorText}`);
225
+ errorCount++;
224
226
  }
225
227
 
226
228
  if (!isLast && onUpdate) {
@@ -230,6 +232,7 @@ async function executeSinglePathEntries(
230
232
  diff: diffTexts.join("\n"),
231
233
  firstChangedLine,
232
234
  },
235
+ ...(errorCount > 0 ? { isError: true } : {}),
233
236
  });
234
237
  }
235
238
  }
@@ -240,6 +243,11 @@ async function executeSinglePathEntries(
240
243
  diff: diffTexts.join("\n"),
241
244
  firstChangedLine,
242
245
  },
246
+ // Any per-entry failure marks the aggregate result as an error so the
247
+ // renderer takes the error branch instead of falling through to the
248
+ // streaming-edit preview (which displays the *proposed* diff and looks
249
+ // indistinguishable from success).
250
+ ...(errorCount > 0 ? { isError: true } : {}),
243
251
  };
244
252
  }
245
253
 
@@ -148,6 +148,8 @@ export interface EditRenderContext {
148
148
  editDiffPreview?: DiffResult | DiffError;
149
149
  /** Multi-file streaming diff preview (edits spanning several files) */
150
150
  perFileDiffPreview?: PerFileDiffPreview[];
151
+ /** Raw in-flight edit text shown while a computed diff preview is unavailable */
152
+ editStreamingFallback?: string;
151
153
  /** Function to render diff text with syntax highlighting */
152
154
  renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
153
155
  }
@@ -272,7 +274,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
272
274
  const parts: string[] = [];
273
275
  for (const preview of previews) {
274
276
  if (!preview.diff && !preview.error) continue;
275
- const header = uiTheme.fg("dim", `\n\n\u2500\u2500 ${shortenPath(preview.path)} \u2500\u2500`);
277
+ const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
276
278
  if (preview.error) {
277
279
  parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`);
278
280
  continue;
@@ -306,6 +308,9 @@ function getCallPreview(
306
308
  if (args.newText || args.patch) {
307
309
  return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme, rawPath);
308
310
  }
311
+ if (renderContext?.editStreamingFallback) {
312
+ return renderContext.editStreamingFallback;
313
+ }
309
314
  return "";
310
315
  }
311
316
 
@@ -13,14 +13,19 @@
13
13
  * the injected `editMode` rather than probing argument shape.
14
14
  */
15
15
 
16
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
16
17
  import {
18
+ ABORT_MARKER,
19
+ BEGIN_PATCH_MARKER,
17
20
  computeHashlineDiff,
18
21
  computeHashlineSectionDiff,
19
22
  containsRecognizableHashlineOperations,
23
+ END_PATCH_MARKER,
20
24
  type HashlineInputSection,
21
25
  splitHashlineInputs,
22
26
  } from "../hashline";
23
27
  import type { Theme } from "../modes/theme/theme";
28
+ import { replaceTabs, truncateToWidth } from "../tools/render-utils";
24
29
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
25
30
  import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
26
31
  import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
@@ -61,6 +66,52 @@ export interface EditStreamingStrategy<Args = unknown> {
61
66
  renderStreamingFallback(args: Args, uiTheme: Theme): string;
62
67
  }
63
68
 
69
+ const STREAMING_FALLBACK_LINES = 12;
70
+ const STREAMING_FALLBACK_WIDTH = 80;
71
+
72
+ function isHashlineHeaderLine(line: string): boolean {
73
+ const trimmed = line.trimEnd();
74
+ return trimmed.startsWith("@") && trimmed.length > 1;
75
+ }
76
+
77
+ function isHashlineEnvelopeMarkerLine(line: string): boolean {
78
+ const trimmed = line.trimEnd();
79
+ return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER;
80
+ }
81
+
82
+ function trimHashlineStreamingSyntax(lines: string[]): string[] {
83
+ let index = lines.findIndex(line => line.trim().length > 0);
84
+ if (index === -1) return [];
85
+
86
+ if (lines[index].trimEnd() === BEGIN_PATCH_MARKER) {
87
+ index++;
88
+ while (index < lines.length && lines[index].trim().length === 0) index++;
89
+ }
90
+ if (index < lines.length && isHashlineHeaderLine(lines[index])) {
91
+ index++;
92
+ }
93
+
94
+ return lines.slice(index).filter(line => !isHashlineEnvelopeMarkerLine(line));
95
+ }
96
+
97
+ function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
98
+ const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n"));
99
+ if (!lines.some(line => line.trim().length > 0)) return "";
100
+
101
+ const displayLines = lines.slice(-STREAMING_FALLBACK_LINES);
102
+ const hidden = lines.length - displayLines.length;
103
+ let text = "\n\n";
104
+ text += displayLines
105
+ .map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH)))
106
+ .join("\n");
107
+ if (hidden > 0) {
108
+ text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`);
109
+ } else {
110
+ text += uiTheme.fg("dim", "\n(streaming)");
111
+ }
112
+ return text;
113
+ }
114
+
64
115
  // -----------------------------------------------------------------------------
65
116
  // Partial-JSON handling
66
117
  // -----------------------------------------------------------------------------
@@ -273,8 +324,8 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
273
324
  }
274
325
  return previews.length > 0 ? previews : null;
275
326
  },
276
- renderStreamingFallback() {
277
- return "";
327
+ renderStreamingFallback(args, uiTheme) {
328
+ return typeof args.input === "string" ? renderHashlineInputFallback(args.input, uiTheme) : "";
278
329
  },
279
330
  };
280
331
 
@@ -32,9 +32,6 @@ interface VmHelperOptions {
32
32
  reverse?: boolean;
33
33
  unique?: boolean;
34
34
  count?: boolean;
35
- cwd?: string;
36
- timeoutMs?: number;
37
- timeout?: number;
38
35
  }
39
36
 
40
37
  interface VmContextState {
@@ -303,41 +300,6 @@ async function createHelpers(state: VmContextState) {
303
300
  emitStatus(state, { op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
304
301
  return result;
305
302
  },
306
- run: async (
307
- command: string,
308
- options: VmHelperOptions = {},
309
- ): Promise<{ stdout: string; stderr: string; exit_code: number }> => {
310
- const cwd = options.cwd ? resolvePath(state, options.cwd) : state.cwd;
311
- const timeoutMs =
312
- typeof options.timeoutMs === "number"
313
- ? options.timeoutMs
314
- : typeof options.timeout === "number"
315
- ? options.timeout * 1000
316
- : undefined;
317
- const timeoutSignal =
318
- typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
319
- ? AbortSignal.timeout(timeoutMs)
320
- : undefined;
321
- const signal =
322
- state.currentRun?.signal && timeoutSignal
323
- ? AbortSignal.any([state.currentRun.signal, timeoutSignal])
324
- : (state.currentRun?.signal ?? timeoutSignal);
325
- const child = Bun.spawn(["bash", "-lc", command], {
326
- cwd,
327
- env: getMergedEnv(state),
328
- stdout: "pipe",
329
- stderr: "pipe",
330
- signal,
331
- });
332
- const [stdout, stderr, exit_code] = await Promise.all([
333
- new Response(child.stdout as ReadableStream<Uint8Array>).text(),
334
- new Response(child.stderr as ReadableStream<Uint8Array>).text(),
335
- child.exited,
336
- ]);
337
- const output = `${stdout}${stderr}`.slice(0, 500);
338
- emitStatus(state, { op: "run", cmd: command.slice(0, 120), code: exit_code, output });
339
- return { stdout, stderr, exit_code };
340
- },
341
303
  env: (key?: string, value?: string): string | Record<string, string> | undefined => {
342
304
  if (!key) {
343
305
  const env = Object.fromEntries(Object.entries(getMergedEnv(state)).sort(([a], [b]) => a.localeCompare(b)));
@@ -419,6 +381,7 @@ async function createVmState(
419
381
  atob,
420
382
  btoa,
421
383
  Buffer,
384
+ Bun,
422
385
  process: createProcessSubset(cwd),
423
386
  require: buildRequire(cwd),
424
387
  createRequire,
@@ -12,7 +12,6 @@ if (!globalThis.__omp_js_prelude_loaded__) {
12
12
  const counter = (items, opts = {}) => callHelper("counter", items, toOptions(opts));
13
13
  const diff = (a, b) => callHelper("diff", a, b);
14
14
  const tree = (path = ".", opts = {}) => callHelper("tree", path, toOptions(opts));
15
- const run = (cmd, opts = {}) => callHelper("run", cmd, toOptions(opts));
16
15
  const env = (key, value) => callHelper("env", key, value);
17
16
 
18
17
  const tool = new Proxy(
@@ -67,6 +66,5 @@ if (!globalThis.__omp_js_prelude_loaded__) {
67
66
  globalThis.counter = counter;
68
67
  globalThis.diff = diff;
69
68
  globalThis.tree = tree;
70
- globalThis.run = run;
71
69
  globalThis.env = env;
72
70
  }
@@ -39,6 +39,13 @@ export interface PythonExecutorOptions {
39
39
  useSharedGateway?: boolean;
40
40
  /** Session file path for accessing task outputs */
41
41
  sessionFile?: string;
42
+ /**
43
+ * Effective artifacts directory for the current session. Subagents share
44
+ * the parent's directory, so this can differ from `sessionFile`'s sibling
45
+ * dir. When present, exported to the kernel as `PI_ARTIFACTS_DIR` and
46
+ * preferred over `PI_SESSION_FILE`-derived paths.
47
+ */
48
+ artifactsDir?: string;
42
49
  /** Artifact path/id for full output storage */
43
50
  artifactPath?: string;
44
51
  artifactId?: string;
@@ -102,6 +109,7 @@ let cleanupTimer: NodeJS.Timeout | null = null;
102
109
  interface KernelSessionExecutionOptions {
103
110
  useSharedGateway?: boolean;
104
111
  sessionFile?: string;
112
+ artifactsDir?: string;
105
113
  signal?: AbortSignal;
106
114
  deadlineMs?: number;
107
115
  kernelOwnerId?: string;
@@ -123,6 +131,19 @@ function getExecutionDeadlineMs(options?: Pick<PythonExecutorOptions, "deadlineM
123
131
  return Date.now() + options.timeoutMs;
124
132
  }
125
133
 
134
+ /**
135
+ * Build the env block exposed to the Python kernel. Includes the session file
136
+ * (for things that need the raw session path) and the effective artifacts
137
+ * directory (preferred by the prelude when resolving output IDs, so subagents
138
+ * see the parent's flat dir instead of a non-existent sibling).
139
+ */
140
+ function buildKernelEnv(options: { sessionFile?: string; artifactsDir?: string }): Record<string, string> | undefined {
141
+ const env: Record<string, string> = {};
142
+ if (options.sessionFile) env.PI_SESSION_FILE = options.sessionFile;
143
+ if (options.artifactsDir) env.PI_ARTIFACTS_DIR = options.artifactsDir;
144
+ return Object.keys(env).length > 0 ? env : undefined;
145
+ }
146
+
126
147
  function getRemainingTimeoutMs(deadlineMs?: number): number | undefined {
127
148
  if (deadlineMs === undefined) return undefined;
128
149
  return deadlineMs - Date.now();
@@ -523,9 +544,7 @@ async function createKernelSession(
523
544
  isRetry?: boolean,
524
545
  ): Promise<KernelSession> {
525
546
  requireRemainingTimeoutMs(options.deadlineMs);
526
- const env: Record<string, string> | undefined = options.sessionFile
527
- ? { PI_SESSION_FILE: options.sessionFile }
528
- : undefined;
547
+ const env = buildKernelEnv(options);
529
548
  const startOptions = buildKernelStartOptions(cwd, env, options);
530
549
 
531
550
  let kernel: PythonKernel;
@@ -586,9 +605,7 @@ async function restartKernelSession(
586
605
  });
587
606
  }
588
607
  }
589
- const env: Record<string, string> | undefined = options.sessionFile
590
- ? { PI_SESSION_FILE: options.sessionFile }
591
- : undefined;
608
+ const env = buildKernelEnv(options);
592
609
  const startOptions = buildKernelStartOptions(cwd, env, options);
593
610
  const kernel = await PythonKernel.start(startOptions);
594
611
  session.kernel = kernel;
@@ -936,10 +953,9 @@ export async function executePython(code: string, options?: PythonExecutorOption
936
953
  await ensureKernelAvailable(cwd);
937
954
 
938
955
  const kernelMode = executionOptions.kernelMode ?? "session";
939
- const sessionFile = executionOptions.sessionFile;
940
956
 
941
957
  if (kernelMode === "per-call") {
942
- const env: Record<string, string> | undefined = sessionFile ? { PI_SESSION_FILE: sessionFile } : undefined;
958
+ const env = buildKernelEnv(executionOptions);
943
959
  requireRemainingTimeoutMs(deadlineMs);
944
960
  const startOptions = buildKernelStartOptions(cwd, env, executionOptions);
945
961
  const kernel = await PythonKernel.start(startOptions);
@@ -35,6 +35,7 @@ export default {
35
35
  kernelMode,
36
36
  useSharedGateway,
37
37
  sessionFile: opts.sessionFile,
38
+ artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
38
39
  kernelOwnerId: opts.kernelOwnerId,
39
40
  reset: opts.reset,
40
41
  artifactPath: opts.artifactPath,