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

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 (108) hide show
  1. package/CHANGELOG.md +96 -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/cli/setup-cli.ts +14 -161
  6. package/src/cli/stats-cli.ts +56 -2
  7. package/src/cli.ts +0 -1
  8. package/src/config/model-registry.ts +13 -0
  9. package/src/config/model-resolver.ts +8 -2
  10. package/src/config/settings-schema.ts +1 -11
  11. package/src/edit/index.ts +8 -0
  12. package/src/edit/renderer.ts +6 -1
  13. package/src/edit/streaming.ts +53 -2
  14. package/src/eval/eval.lark +30 -10
  15. package/src/eval/js/context-manager.ts +334 -601
  16. package/src/eval/js/shared/helpers.ts +237 -0
  17. package/src/eval/js/shared/indirect-eval.ts +30 -0
  18. package/src/eval/js/{prelude.txt → shared/prelude.txt} +0 -2
  19. package/src/eval/js/shared/rewrite-imports.ts +211 -0
  20. package/src/eval/js/shared/runtime.ts +168 -0
  21. package/src/eval/js/shared/types.ts +18 -0
  22. package/src/eval/js/tool-bridge.ts +2 -4
  23. package/src/eval/js/worker-core.ts +146 -0
  24. package/src/eval/js/worker-entry.ts +24 -0
  25. package/src/eval/js/worker-protocol.ts +41 -0
  26. package/src/eval/parse.ts +218 -49
  27. package/src/eval/py/display.ts +71 -0
  28. package/src/eval/py/executor.ts +97 -96
  29. package/src/eval/py/index.ts +2 -2
  30. package/src/eval/py/kernel.ts +472 -900
  31. package/src/eval/py/prelude.py +106 -87
  32. package/src/eval/py/runner.py +879 -0
  33. package/src/eval/py/runtime.ts +3 -16
  34. package/src/eval/py/tool-bridge.ts +137 -0
  35. package/src/export/html/template.css +12 -0
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +113 -7
  38. package/src/extensibility/plugins/loader.ts +31 -6
  39. package/src/extensibility/skills.ts +20 -0
  40. package/src/internal-urls/agent-protocol.ts +63 -52
  41. package/src/internal-urls/artifact-protocol.ts +51 -51
  42. package/src/internal-urls/docs-index.generated.ts +35 -3
  43. package/src/internal-urls/index.ts +6 -19
  44. package/src/internal-urls/local-protocol.ts +49 -7
  45. package/src/internal-urls/mcp-protocol.ts +2 -8
  46. package/src/internal-urls/memory-protocol.ts +89 -59
  47. package/src/internal-urls/router.ts +38 -22
  48. package/src/internal-urls/rule-protocol.ts +2 -20
  49. package/src/internal-urls/skill-protocol.ts +4 -27
  50. package/src/main.ts +1 -1
  51. package/src/mcp/manager.ts +17 -0
  52. package/src/modes/components/session-observer-overlay.ts +2 -2
  53. package/src/modes/components/tool-execution.ts +6 -0
  54. package/src/modes/components/tree-selector.ts +4 -0
  55. package/src/modes/controllers/command-controller.ts +0 -23
  56. package/src/modes/controllers/event-controller.ts +23 -2
  57. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  58. package/src/modes/interactive-mode.ts +2 -2
  59. package/src/modes/theme/theme.ts +27 -27
  60. package/src/modes/types.ts +1 -1
  61. package/src/modes/utils/ui-helpers.ts +14 -9
  62. package/src/prompts/commands/orchestrate.md +1 -0
  63. package/src/prompts/system/project-prompt.md +10 -2
  64. package/src/prompts/system/subagent-system-prompt.md +8 -8
  65. package/src/prompts/system/system-prompt.md +13 -7
  66. package/src/prompts/tools/ask.md +0 -1
  67. package/src/prompts/tools/bash.md +0 -10
  68. package/src/prompts/tools/eval.md +15 -30
  69. package/src/prompts/tools/github.md +6 -5
  70. package/src/prompts/tools/hashline.md +1 -0
  71. package/src/prompts/tools/job.md +14 -6
  72. package/src/prompts/tools/task.md +20 -3
  73. package/src/registry/agent-registry.ts +2 -1
  74. package/src/sdk.ts +87 -89
  75. package/src/session/agent-session.ts +58 -21
  76. package/src/session/artifacts.ts +7 -4
  77. package/src/session/history-storage.ts +77 -19
  78. package/src/session/session-manager.ts +30 -1
  79. package/src/ssh/connection-manager.ts +32 -16
  80. package/src/ssh/sshfs-mount.ts +10 -7
  81. package/src/system-prompt.ts +0 -5
  82. package/src/task/executor.ts +14 -2
  83. package/src/task/index.ts +19 -5
  84. package/src/tool-discovery/tool-index.ts +21 -8
  85. package/src/tools/ast-edit.ts +3 -2
  86. package/src/tools/ast-grep.ts +3 -2
  87. package/src/tools/bash.ts +15 -9
  88. package/src/tools/browser/tab-protocol.ts +4 -0
  89. package/src/tools/browser/tab-supervisor.ts +98 -7
  90. package/src/tools/browser/tab-worker.ts +104 -58
  91. package/src/tools/eval.ts +49 -11
  92. package/src/tools/fetch.ts +1 -1
  93. package/src/tools/gh.ts +140 -4
  94. package/src/tools/index.ts +12 -11
  95. package/src/tools/job.ts +48 -12
  96. package/src/tools/read.ts +5 -4
  97. package/src/tools/search.ts +3 -2
  98. package/src/tools/todo-write.ts +1 -1
  99. package/src/web/scrapers/mastodon.ts +1 -1
  100. package/src/web/scrapers/repology.ts +7 -7
  101. package/src/web/search/index.ts +6 -4
  102. package/src/cli/jupyter-cli.ts +0 -106
  103. package/src/commands/jupyter.ts +0 -32
  104. package/src/eval/py/cancellation.ts +0 -28
  105. package/src/eval/py/gateway-coordinator.ts +0 -424
  106. package/src/internal-urls/jobs-protocol.ts +0 -120
  107. package/src/prompts/system/now-prompt.md +0 -7
  108. /package/src/eval/js/{prelude.ts → shared/prelude.ts} +0 -0
@@ -19,6 +19,12 @@ type HistoryRow = {
19
19
 
20
20
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
21
21
 
22
+ // Escape LIKE wildcards so user input is treated as literal text.
23
+ // Matches the `ESCAPE '\\'` clause used by substring-search statements.
24
+ function escapeLikePattern(text: string): string {
25
+ return text.replace(/[\\%_]/g, "\\$&");
26
+ }
27
+
22
28
  class AsyncDrain<T> {
23
29
  #queue?: T[];
24
30
  #promise = Promise.resolve();
@@ -63,6 +69,8 @@ export class HistoryStorage {
63
69
  #recentStmt: Statement;
64
70
  #searchStmt: Statement;
65
71
  #lastPromptStmt: Statement;
72
+ // Cache substring-fallback prepared statements keyed by token count.
73
+ #substringStmts = new Map<number, Statement>();
66
74
 
67
75
  // In-memory cache of last prompt to avoid sync DB reads on add
68
76
  #lastPromptCache: string | null = null;
@@ -167,16 +175,53 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
167
175
  const safeLimit = this.#normalizeLimit(limit);
168
176
  if (safeLimit === 0) return [];
169
177
 
170
- const ftsQuery = this.#buildFtsQuery(query);
171
- if (!ftsQuery) return [];
178
+ const tokens = this.#tokenize(query);
179
+ if (tokens.length === 0) return [];
172
180
 
181
+ // 1. FTS5 prefix match (token AND, prefix-wildcard per token).
182
+ // Handles punctuation by tokenizing query the same way unicode61 tokenizer
183
+ // indexed the stored text, so "git-commit" -> "git"* "commit"*.
184
+ const ftsQuery = tokens.map(tok => `"${tok.replace(/"/g, '""')}"*`).join(" ");
185
+ let ftsRows: HistoryRow[] = [];
173
186
  try {
174
- const rows = this.#searchStmt.all(ftsQuery, safeLimit) as HistoryRow[];
175
- return rows.map(row => this.#toEntry(row));
187
+ ftsRows = this.#searchStmt.all(ftsQuery, safeLimit) as HistoryRow[];
176
188
  } catch (error) {
177
- logger.error("HistoryStorage search failed", { error: String(error) });
178
- return [];
189
+ // Malformed FTS expression - fall through to substring path.
190
+ logger.debug("HistoryStorage FTS query failed, using substring only", { error: String(error) });
191
+ }
192
+
193
+ if (ftsRows.length >= safeLimit) {
194
+ return ftsRows.map(row => this.#toEntry(row));
195
+ }
196
+
197
+ // 2. Substring fallback (token-AND LIKE). Catches infix matches FTS5's
198
+ // prefix-only wildcard cannot reach (e.g. "mit" -> "commit"). Bounded
199
+ // by safeLimit, ordered by recency - no full-table load into JS.
200
+ let subRows: HistoryRow[] = [];
201
+ try {
202
+ subRows = this.#searchSubstring(tokens, safeLimit);
203
+ } catch (error) {
204
+ logger.error("HistoryStorage substring search failed", { error: String(error) });
205
+ }
206
+
207
+ if (ftsRows.length === 0) {
208
+ return subRows.map(row => this.#toEntry(row));
209
+ }
210
+
211
+ const seen = new Set<number>();
212
+ const merged: HistoryEntry[] = [];
213
+ for (const row of ftsRows) {
214
+ if (seen.has(row.id)) continue;
215
+ seen.add(row.id);
216
+ merged.push(this.#toEntry(row));
179
217
  }
218
+ for (const row of subRows) {
219
+ if (merged.length >= safeLimit) break;
220
+ if (seen.has(row.id)) continue;
221
+ seen.add(row.id);
222
+ merged.push(this.#toEntry(row));
223
+ }
224
+ return merged;
180
225
  }
181
226
 
182
227
  #ensureDir(dbPath: string): void {
@@ -225,21 +270,34 @@ END;
225
270
  return Math.min(clamped, 1000);
226
271
  }
227
272
 
228
- #buildFtsQuery(query: string): string | null {
229
- const tokens = query
230
- .trim()
231
- .split(/\s+/)
232
- .map(token => token.trim())
233
- .filter(Boolean);
273
+ /**
274
+ * Split on non-alphanumeric runs, mirroring FTS5's `unicode61` tokenizer so
275
+ * query tokens align with how stored prompts were indexed. Lowercases for
276
+ * stable substring matching.
277
+ */
278
+ #tokenize(query: string): string[] {
279
+ return query
280
+ .toLowerCase()
281
+ .split(/[^\p{L}\p{N}]+/u)
282
+ .filter(tok => tok.length > 0);
283
+ }
234
284
 
235
- if (tokens.length === 0) return null;
285
+ #searchSubstring(tokens: string[], limit: number): HistoryRow[] {
286
+ const stmt = this.#getSubstringStmt(tokens.length);
287
+ const params: unknown[] = tokens.map(tok => `%${escapeLikePattern(tok)}%`);
288
+ params.push(limit);
289
+ return stmt.all(...(params as [string, ...unknown[]])) as HistoryRow[];
290
+ }
236
291
 
237
- return tokens
238
- .map(token => {
239
- const escaped = token.replace(/"/g, '""');
240
- return `"${escaped}"*`;
241
- })
242
- .join(" ");
292
+ #getSubstringStmt(tokenCount: number): Statement {
293
+ let stmt = this.#substringStmts.get(tokenCount);
294
+ if (stmt) return stmt;
295
+ const whereClause = Array(tokenCount).fill("prompt LIKE ? ESCAPE '\\' COLLATE NOCASE").join(" AND ");
296
+ stmt = this.#db.prepare(
297
+ `SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
298
+ );
299
+ this.#substringStmts.set(tokenCount, stmt);
300
+ return stmt;
243
301
  }
244
302
 
245
303
  #toEntry(row: HistoryRow): HistoryEntry {
@@ -275,6 +275,7 @@ export type ReadonlySessionManager = Pick<
275
275
  | "getSessionFile"
276
276
  | "getSessionName"
277
277
  | "getArtifactsDir"
278
+ | "getArtifactManager"
278
279
  | "allocateArtifactPath"
279
280
  | "saveArtifact"
280
281
  | "getArtifactPath"
@@ -1622,6 +1623,10 @@ export class SessionManager {
1622
1623
  #persistErrorReported = false;
1623
1624
  #artifactManager: ArtifactManager | null = null;
1624
1625
  #artifactManagerSessionFile: string | null = null;
1626
+ // When set, take precedence over the lazily-derived per-session manager.
1627
+ // Subagents adopt the parent's manager so artifact IDs are unique across the
1628
+ // whole agent tree and all files land in the parent's artifacts dir.
1629
+ #adoptedArtifactManager: ArtifactManager | null = null;
1625
1630
  // In-memory artifact fallback for non-persistent sessions (persist=false).
1626
1631
  // Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
1627
1632
  #inMemoryArtifacts: Map<string, string> | null = null;
@@ -1675,6 +1680,7 @@ export class SessionManager {
1675
1680
  this.#persistErrorReported = false;
1676
1681
  this.#artifactManager = null;
1677
1682
  this.#artifactManagerSessionFile = null;
1683
+ this.#adoptedArtifactManager = null;
1678
1684
  this.#buildIndex();
1679
1685
  if (this.#sessionFile) {
1680
1686
  writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
@@ -2120,17 +2126,40 @@ export class SessionManager {
2120
2126
  /**
2121
2127
  * Returns the session artifacts directory path (session file path without .jsonl).
2122
2128
  * Returns null when the session is not persisted to a file.
2129
+ * When this session has adopted an external ArtifactManager (subagent case),
2130
+ * returns that manager's directory so reads/writes land in the shared parent
2131
+ * dir instead of a private (non-existent) subdir.
2123
2132
  */
2124
2133
  getArtifactsDir(): string | null {
2134
+ if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
2125
2135
  const sessionFile = this.#sessionFile;
2126
2136
  return sessionFile ? sessionFile.slice(0, -6) : null;
2127
2137
  }
2128
2138
 
2139
+ /**
2140
+ * Adopt an externally-owned ArtifactManager. Used by subagents to share
2141
+ * the parent session's artifact directory and ID counter.
2142
+ */
2143
+ adoptArtifactManager(manager: ArtifactManager): void {
2144
+ this.#adoptedArtifactManager = manager;
2145
+ }
2146
+
2147
+ /**
2148
+ * Returns the ArtifactManager this session writes through. Lazily creates
2149
+ * one bound to the current session file unless an external manager was
2150
+ * adopted via `adoptArtifactManager`. Returns null only for non-persistent
2151
+ * sessions with no adopted manager.
2152
+ */
2153
+ getArtifactManager(): ArtifactManager | null {
2154
+ return this.#getOrCreateArtifactManager();
2155
+ }
2156
+
2129
2157
  /**
2130
2158
  * Returns an artifact manager bound to the current session file.
2131
2159
  * Recreates the manager when the active session file changes.
2132
2160
  */
2133
2161
  #getOrCreateArtifactManager(): ArtifactManager | null {
2162
+ if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
2134
2163
  const sessionFile = this.#sessionFile;
2135
2164
  if (!sessionFile) {
2136
2165
  this.#artifactManager = null;
@@ -2142,7 +2171,7 @@ export class SessionManager {
2142
2171
  return this.#artifactManager;
2143
2172
  }
2144
2173
 
2145
- const manager = new ArtifactManager(sessionFile);
2174
+ const manager = new ArtifactManager(sessionFile.slice(0, -6));
2146
2175
  this.#artifactManager = manager;
2147
2176
  this.#artifactManagerSessionFile = sessionFile;
2148
2177
  return manager;
@@ -15,6 +15,11 @@ export interface SSHConnectionTarget {
15
15
 
16
16
  export type SSHHostOs = "windows" | "linux" | "macos" | "unknown";
17
17
  export type SSHHostShell = "cmd" | "powershell" | "bash" | "zsh" | "sh" | "unknown";
18
+ export type SshPlatform = typeof process.platform;
19
+
20
+ export function supportsSshControlMaster(platform: SshPlatform = process.platform): boolean {
21
+ return platform !== "win32";
22
+ }
18
23
 
19
24
  export interface SSHHostInfo {
20
25
  version: number;
@@ -33,6 +38,10 @@ const activeHosts = new Map<string, SSHConnectionTarget>();
33
38
  const pendingConnections = new Map<string, Promise<void>>();
34
39
  const hostInfoCache = new Map<string, SSHHostInfo>();
35
40
 
41
+ interface SSHArgsOptions {
42
+ platform?: SshPlatform;
43
+ }
44
+
36
45
  function ensureControlDir() {
37
46
  fs.mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 });
38
47
  try {
@@ -66,20 +75,14 @@ async function validateKeyPermissions(keyPath?: string): Promise<void> {
66
75
  }
67
76
  }
68
77
 
69
- function buildCommonArgs(host: SSHConnectionTarget): string[] {
70
- const args = [
71
- "-n",
72
- "-o",
73
- "ControlMaster=auto",
74
- "-o",
75
- `ControlPath=${CONTROL_PATH}`,
76
- "-o",
77
- "ControlPersist=3600",
78
- "-o",
79
- "BatchMode=yes",
80
- "-o",
81
- "StrictHostKeyChecking=accept-new",
82
- ];
78
+ function buildCommonArgs(host: SSHConnectionTarget, options?: SSHArgsOptions): string[] {
79
+ const args = ["-n"];
80
+
81
+ if (supportsSshControlMaster(options?.platform)) {
82
+ args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${CONTROL_PATH}`, "-o", "ControlPersist=3600");
83
+ }
84
+
85
+ args.push("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new");
83
86
 
84
87
  if (host.port) {
85
88
  args.push("-p", String(host.port));
@@ -357,9 +360,13 @@ export async function ensureHostInfo(host: SSHConnectionTarget): Promise<SSHHost
357
360
  return probeHostInfo(host);
358
361
  }
359
362
 
360
- export async function buildRemoteCommand(host: SSHConnectionTarget, command: string): Promise<string[]> {
363
+ export async function buildRemoteCommand(
364
+ host: SSHConnectionTarget,
365
+ command: string,
366
+ options?: SSHArgsOptions,
367
+ ): Promise<string[]> {
361
368
  await validateKeyPermissions(host.keyPath);
362
- return [...buildCommonArgs(host), buildSshTarget(host.username, host.host), command];
369
+ return [...buildCommonArgs(host, options), buildSshTarget(host.username, host.host), command];
363
370
  }
364
371
 
365
372
  let registered = false;
@@ -385,6 +392,14 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise<void>
385
392
  }
386
393
 
387
394
  const target = buildSshTarget(host.username, host.host);
395
+ if (!supportsSshControlMaster()) {
396
+ activeHosts.set(key, host);
397
+ if (!hostInfoCache.has(key) && !(await loadHostInfoFromDisk(host))) {
398
+ await probeHostInfo(host);
399
+ }
400
+ return;
401
+ }
402
+
388
403
  const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
389
404
  if (check.exitCode === 0) {
390
405
  activeHosts.set(key, host);
@@ -415,6 +430,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise<void>
415
430
  }
416
431
 
417
432
  async function closeConnectionInternal(host: SSHConnectionTarget): Promise<void> {
433
+ if (!supportsSshControlMaster()) return;
418
434
  const target = buildSshTarget(host.username, host.host);
419
435
  await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
420
436
  }
@@ -2,7 +2,12 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { $which, getRemoteDir, postmortem } from "@oh-my-pi/pi-utils";
4
4
  import { $ } from "bun";
5
- import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager";
5
+ import {
6
+ getControlDir,
7
+ getControlPathTemplate,
8
+ type SSHConnectionTarget,
9
+ supportsSshControlMaster,
10
+ } from "./connection-manager";
6
11
  import { buildSshTarget, sanitizeHostName } from "./utils";
7
12
 
8
13
  const REMOTE_DIR = getRemoteDir();
@@ -40,14 +45,12 @@ function buildSshfsArgs(host: SSHConnectionTarget): string[] {
40
45
  "BatchMode=yes",
41
46
  "-o",
42
47
  "StrictHostKeyChecking=accept-new",
43
- "-o",
44
- "ControlMaster=auto",
45
- "-o",
46
- `ControlPath=${CONTROL_PATH}`,
47
- "-o",
48
- "ControlPersist=3600",
49
48
  ];
50
49
 
50
+ if (supportsSshControlMaster()) {
51
+ args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${CONTROL_PATH}`, "-o", "ControlPersist=3600");
52
+ }
53
+
51
54
  if (host.port) {
52
55
  args.push("-p", String(host.port));
53
56
  }
@@ -12,7 +12,6 @@ import type { SkillsSettings } from "./config/settings";
12
12
  import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
13
13
  import { loadSkills, type Skill } from "./extensibility/skills";
14
14
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
15
- import nowPromptTemplate from "./prompts/system/now-prompt.md" with { type: "text" };
16
15
  import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
17
16
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
18
17
  import { shortenPath } from "./tools/render-utils";
@@ -575,10 +574,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
575
574
  if (projectPrompt) {
576
575
  systemPrompt.push(projectPrompt);
577
576
  }
578
- const nowPrompt = prompt.render(nowPromptTemplate, data).trim();
579
- if (nowPrompt) {
580
- systemPrompt.push(nowPrompt);
581
- }
582
577
 
583
578
  return { systemPrompt };
584
579
  }
@@ -26,9 +26,11 @@ import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md
26
26
  import { AgentRegistry } from "../registry/agent-registry";
27
27
  import { createAgentSession, discoverAuthStorage } from "../sdk";
28
28
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
29
+ import type { ArtifactManager } from "../session/artifacts";
29
30
  import type { AuthStorage } from "../session/auth-storage";
30
31
  import { SessionManager } from "../session/session-manager";
31
- import { type ContextFileEntry, truncateTail } from "../tools";
32
+ import { truncateTail } from "../session/streaming-output";
33
+ import type { ContextFileEntry } from "../tools";
32
34
  import { jtdToJsonSchema, normalizeSchema } from "../tools/jtd-to-json-schema";
33
35
  import { ToolAbortError } from "../tools/tool-errors";
34
36
  import type { EventBus } from "../utils/event-bus";
@@ -172,6 +174,12 @@ export interface ExecutorOptions {
172
174
  settings?: Settings;
173
175
  /** Override local:// protocol options so subagent shares parent's local:// root */
174
176
  localProtocolOptions?: LocalProtocolOptions;
177
+ /**
178
+ * Parent session's ArtifactManager. Subagent adopts it so artifact IDs are
179
+ * unique across the whole agent tree and all artifacts land in the parent's
180
+ * artifacts directory (no per-subagent subdir).
181
+ */
182
+ parentArtifactManager?: ArtifactManager;
175
183
  parentHindsightSessionState?: HindsightSessionState;
176
184
  }
177
185
 
@@ -563,6 +571,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
563
571
 
564
572
  const lspEnabled = enableLsp ?? true;
565
573
  const ircEnabled = subagentSettings.get("irc.enabled") === true;
574
+ const contextFileForPrompt = ircEnabled ? undefined : options.contextFile;
566
575
  const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
567
576
 
568
577
  const outputChunks: string[] = [];
@@ -975,6 +984,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
975
984
  const sessionManager = sessionFile
976
985
  ? await SessionManager.open(sessionFile)
977
986
  : SessionManager.inMemory(worktree ?? cwd);
987
+ if (options.parentArtifactManager) {
988
+ sessionManager.adoptArtifactManager(options.parentArtifactManager);
989
+ }
978
990
 
979
991
  const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
980
992
  const enableMCP = !options.mcpManager;
@@ -1001,7 +1013,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1001
1013
  context: options.context?.trim() ?? "",
1002
1014
  worktree: worktree ?? "",
1003
1015
  outputSchema: normalizedOutputSchema,
1004
- contextFile: options.contextFile,
1016
+ contextFile: contextFileForPrompt,
1005
1017
  ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
1006
1018
  ircSelfId: ircEnabled ? id : "",
1007
1019
  });
package/src/task/index.ts CHANGED
@@ -20,7 +20,9 @@ import type { Usage } from "@oh-my-pi/pi-ai";
20
20
  import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
21
21
  import type { TSchema } from "@sinclair/typebox";
22
22
  import type { ToolSession } from "..";
23
+ import { AsyncJobManager } from "../async";
23
24
  import { resolveAgentModelPatterns } from "../config/model-resolver";
25
+ import { MCPManager } from "../mcp/manager";
24
26
  import type { Theme } from "../modes/theme/theme";
25
27
  import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
26
28
  import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
@@ -141,6 +143,7 @@ function renderDescription(
141
143
  asyncEnabled: boolean,
142
144
  disabledAgents: string[],
143
145
  simpleMode: TaskSimpleMode,
146
+ ircEnabled: boolean,
144
147
  ): string {
145
148
  const filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
146
149
  const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
@@ -151,6 +154,7 @@ function renderDescription(
151
154
  asyncEnabled,
152
155
  contextEnabled,
153
156
  customSchemaEnabled,
157
+ ircEnabled,
154
158
  defaultMode: simpleMode === "default",
155
159
  schemaFreeMode: simpleMode === "schema-free",
156
160
  independentMode: simpleMode === "independent",
@@ -229,6 +233,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
229
233
  this.session.settings.get("async.enabled"),
230
234
  disabledAgents,
231
235
  this.#getTaskSimpleMode(),
236
+ this.session.settings.get("irc.enabled") === true,
232
237
  );
233
238
  }
234
239
  private constructor(
@@ -270,7 +275,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
270
275
  return this.#executeSync(_toolCallId, params, signal, onUpdate);
271
276
  }
272
277
 
273
- const manager = this.session.asyncJobManager;
278
+ const manager = AsyncJobManager.instance();
274
279
  if (!manager) {
275
280
  return {
276
281
  content: [{ type: "text", text: "Async execution is enabled but no async job manager is available." }],
@@ -444,6 +449,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
444
449
  },
445
450
  {
446
451
  id: label,
452
+ ownerId: this.session.getAgentId?.() ?? undefined,
447
453
  onProgress: (text, details) => {
448
454
  const progressDetails =
449
455
  (details as TaskToolDetails | undefined) ??
@@ -729,6 +735,10 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
729
735
  getSessionId: this.session.getSessionId ?? (() => null),
730
736
  };
731
737
 
738
+ // Subagents adopt the parent's ArtifactManager so artifact IDs are unique
739
+ // across the whole tree and outputs land flat in the parent's dir.
740
+ const parentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
741
+
732
742
  // Initialize progress tracking
733
743
  const progressMap = new Map<number, AgentProgress>();
734
744
 
@@ -785,9 +795,11 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
785
795
  };
786
796
  }
787
797
 
788
- // Write parent conversation context for subagents
798
+ // Write parent conversation context for subagents. When IRC is available,
799
+ // subagents should ask live peers instead of reading a stale markdown dump.
789
800
  await fs.mkdir(effectiveArtifactsDir, { recursive: true });
790
- const compactContext = this.session.getCompactContext?.();
801
+ const shouldWriteConversationContext = this.session.settings.get("irc.enabled") !== true;
802
+ const compactContext = shouldWriteConversationContext ? this.session.getCompactContext?.() : undefined;
791
803
  let contextFilePath: string | undefined;
792
804
  if (compactContext) {
793
805
  contextFilePath = path.join(effectiveArtifactsDir, "context.md");
@@ -867,12 +879,13 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
867
879
  authStorage: this.session.authStorage,
868
880
  modelRegistry: this.session.modelRegistry,
869
881
  settings: this.session.settings,
870
- mcpManager: this.session.mcpManager,
882
+ mcpManager: MCPManager.instance(),
871
883
  contextFiles,
872
884
  skills: availableSkills,
873
885
  workspaceTree: this.session.workspaceTree,
874
886
  promptTemplates,
875
887
  localProtocolOptions,
888
+ parentArtifactManager,
876
889
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
877
890
  });
878
891
  }
@@ -925,12 +938,13 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
925
938
  authStorage: this.session.authStorage,
926
939
  modelRegistry: this.session.modelRegistry,
927
940
  settings: this.session.settings,
928
- mcpManager: this.session.mcpManager,
941
+ mcpManager: MCPManager.instance(),
929
942
  contextFiles,
930
943
  skills: availableSkills,
931
944
  workspaceTree: this.session.workspaceTree,
932
945
  promptTemplates,
933
946
  localProtocolOptions,
947
+ parentArtifactManager,
934
948
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
935
949
  });
936
950
  if (mergeMode === "branch" && result.exitCode === 0) {
@@ -89,6 +89,7 @@ export interface DiscoverableMCPSearchResult {
89
89
 
90
90
  const BM25_K1 = 1.2;
91
91
  const BM25_B = 0.75;
92
+ const BM25_DELTA = 1.0;
92
93
  const FIELD_WEIGHTS = {
93
94
  name: 6,
94
95
  label: 4,
@@ -112,13 +113,24 @@ function getSchemaPropertyKeys(parameters: unknown): string[] {
112
113
  }
113
114
 
114
115
  function tokenize(value: string): string[] {
115
- return value
116
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
117
- .replace(/[^a-zA-Z0-9]+/g, " ")
118
- .toLowerCase()
119
- .trim()
120
- .split(/\s+/)
121
- .filter(token => token.length > 0);
116
+ return (
117
+ value
118
+ .normalize("NFKD")
119
+ // Drop combining marks (accents) so "café" → "cafe".
120
+ .replace(/\p{M}+/gu, "")
121
+ // Split ACRONYMBoundary: "MCPTool" → "MCP Tool".
122
+ .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2")
123
+ // Split camelCase / digit→letter: "fooBar" → "foo Bar", "v2Beta" → "v2 Beta".
124
+ .replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
125
+ // Everything that isn't a letter or digit becomes a separator. This subsumes markdown
126
+ // punctuation (`|*_`#-~>[]()`), box-drawing glyphs (─│┌), em/en dashes, smart quotes,
127
+ // zero-width spaces, NBSPs, etc.
128
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
129
+ .toLowerCase()
130
+ .trim()
131
+ .split(/\s+/)
132
+ .filter(token => token.length > 0)
133
+ );
122
134
  }
123
135
 
124
136
  function addWeightedTokens(termFrequencies: Map<string, number>, value: string | undefined, weight: number): void {
@@ -274,7 +286,8 @@ export function searchDiscoverableTools(
274
286
  const documentFrequency = index.documentFrequencies.get(token) ?? 0;
275
287
  const idf = Math.log(1 + (index.documents.length - documentFrequency + 0.5) / (documentFrequency + 0.5));
276
288
  const normalization = BM25_K1 * (1 - BM25_B + BM25_B * (document.length / index.averageLength));
277
- score += queryTermCount * idf * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + normalization));
289
+ score +=
290
+ queryTermCount * idf * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + normalization) + BM25_DELTA);
278
291
  }
279
292
  return { tool: document.tool, score };
280
293
  })
@@ -7,6 +7,7 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
10
+ import { InternalUrlRouter } from "../internal-urls";
10
11
  import type { Theme } from "../modes/theme/theme";
11
12
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
12
13
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
@@ -213,10 +214,10 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
213
214
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
214
215
  throw new ToolError("`paths` must contain non-empty paths or globs");
215
216
  }
216
- const internalRouter = this.session.internalRouter;
217
+ const internalRouter = InternalUrlRouter.instance();
217
218
  const resolvedPathInputs: string[] = [];
218
219
  for (const rawPath of rawPaths) {
219
- if (!internalRouter?.canHandle(rawPath)) {
220
+ if (!internalRouter.canHandle(rawPath)) {
220
221
  resolvedPathInputs.push(rawPath);
221
222
  continue;
222
223
  }
@@ -6,6 +6,7 @@ import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
+ import { InternalUrlRouter } from "../internal-urls";
9
10
  import type { Theme } from "../modes/theme/theme";
10
11
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
11
12
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
@@ -158,10 +159,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
158
159
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
159
160
  throw new ToolError("`paths` must contain non-empty paths or globs");
160
161
  }
161
- const internalRouter = this.session.internalRouter;
162
+ const internalRouter = InternalUrlRouter.instance();
162
163
  const resolvedPathInputs: string[] = [];
163
164
  for (const rawPath of rawPaths) {
164
- if (!internalRouter?.canHandle(rawPath)) {
165
+ if (!internalRouter.canHandle(rawPath)) {
165
166
  resolvedPathInputs.push(rawPath);
166
167
  continue;
167
168
  }