@oh-my-pi/pi-coding-agent 15.9.3 → 15.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 (63) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +13 -4
  4. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  5. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  6. package/dist/types/modes/components/error-banner.d.ts +11 -0
  7. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  8. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  9. package/dist/types/modes/components/user-message.d.ts +1 -1
  10. package/dist/types/modes/image-references.d.ts +17 -0
  11. package/dist/types/modes/interactive-mode.d.ts +7 -0
  12. package/dist/types/modes/types.d.ts +7 -0
  13. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  14. package/dist/types/session/blob-store.d.ts +12 -11
  15. package/dist/types/session/session-manager.d.ts +5 -3
  16. package/dist/types/system-prompt.d.ts +2 -0
  17. package/dist/types/tiny/title-client.d.ts +16 -1
  18. package/dist/types/tool-discovery/mode.d.ts +8 -0
  19. package/dist/types/tools/archive-reader.d.ts +5 -1
  20. package/dist/types/tui/hyperlink.d.ts +12 -0
  21. package/dist/types/web/search/render.d.ts +1 -2
  22. package/package.json +9 -9
  23. package/src/cli/classify-install-target.ts +31 -5
  24. package/src/cli/plugin-cli.ts +45 -0
  25. package/src/cli/web-search-cli.ts +0 -1
  26. package/src/config/model-registry.ts +54 -4
  27. package/src/config/settings-schema.ts +14 -4
  28. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  29. package/src/eval/py/tool-bridge.ts +43 -5
  30. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  31. package/src/internal-urls/docs-index.generated.ts +3 -3
  32. package/src/main.ts +7 -1
  33. package/src/modes/components/assistant-message.ts +22 -0
  34. package/src/modes/components/custom-editor.ts +14 -2
  35. package/src/modes/components/error-banner.ts +33 -0
  36. package/src/modes/components/tool-execution.ts +44 -0
  37. package/src/modes/components/transcript-container.ts +93 -32
  38. package/src/modes/components/user-message.ts +9 -2
  39. package/src/modes/controllers/event-controller.ts +42 -3
  40. package/src/modes/controllers/input-controller.ts +33 -1
  41. package/src/modes/image-references.ts +111 -0
  42. package/src/modes/interactive-mode.ts +48 -13
  43. package/src/modes/types.ts +10 -1
  44. package/src/modes/utils/ui-helpers.ts +23 -2
  45. package/src/prompts/ci-green-request.md +5 -3
  46. package/src/prompts/system/project-prompt.md +1 -0
  47. package/src/sdk.ts +17 -9
  48. package/src/session/agent-session.ts +37 -12
  49. package/src/session/blob-store.ts +96 -9
  50. package/src/session/session-manager.ts +19 -10
  51. package/src/system-prompt.ts +4 -0
  52. package/src/tiny/title-client.ts +7 -1
  53. package/src/tool-discovery/mode.ts +24 -0
  54. package/src/tools/archive-reader.ts +339 -31
  55. package/src/tools/fetch.ts +29 -9
  56. package/src/tools/gh.ts +65 -11
  57. package/src/tools/index.ts +6 -8
  58. package/src/tools/read.ts +58 -12
  59. package/src/tools/search-tool-bm25.ts +4 -6
  60. package/src/tools/search.ts +60 -11
  61. package/src/tui/hyperlink.ts +42 -7
  62. package/src/web/search/index.ts +2 -2
  63. package/src/web/search/render.ts +20 -52
@@ -354,6 +354,7 @@ async function handleInstall(
354
354
  console.error(chalk.dim(` ${APP_NAME} plugin install name@marketplace`));
355
355
  console.error(chalk.dim(` ${APP_NAME} plugin install github:user/repo`));
356
356
  console.error(chalk.dim(` ${APP_NAME} plugin install https://github.com/user/repo#v1.0`));
357
+ console.error(chalk.dim(` ${APP_NAME} plugin install ./path/to/local/plugin`));
357
358
  process.exit(1);
358
359
  }
359
360
 
@@ -382,6 +383,49 @@ async function handleInstall(
382
383
  continue;
383
384
  }
384
385
 
386
+ if (target.type === "local") {
387
+ // Local paths route to link(): symlink the directory into the plugins
388
+ // node_modules tree so source edits show up without a reinstall. Matches
389
+ // `omp plugin link <path>` so users can use either verb interchangeably.
390
+ if (flags.scope) {
391
+ console.error(
392
+ chalk.yellow(
393
+ `Warning: --scope is only supported for marketplace installs (name@marketplace). Ignoring for ${spec}.`,
394
+ ),
395
+ );
396
+ }
397
+ if (flags.force) {
398
+ console.error(
399
+ chalk.yellow(
400
+ `Warning: --force has no effect for local path installs (link is already idempotent). Ignoring for ${spec}.`,
401
+ ),
402
+ );
403
+ }
404
+ if (flags.dryRun) {
405
+ if (flags.json) {
406
+ console.log(JSON.stringify({ dryRun: true, action: "link", path: target.path }, null, 2));
407
+ } else {
408
+ console.log(chalk.dim(`[dry-run] Would link ${spec}`));
409
+ }
410
+ continue;
411
+ }
412
+ try {
413
+ const result = await manager.link(target.path);
414
+ if (flags.json) {
415
+ console.log(JSON.stringify(result, null, 2));
416
+ } else {
417
+ console.log(chalk.green(`${theme.status.success} Linked ${result.name} from ${spec}`));
418
+ if (result.manifest.description) {
419
+ console.log(chalk.dim(` ${result.manifest.description}`));
420
+ }
421
+ }
422
+ } catch (err) {
423
+ console.error(chalk.red(`${theme.status.error} Failed to install ${spec}: ${err}`));
424
+ process.exit(1);
425
+ }
426
+ continue;
427
+ }
428
+
385
429
  // --scope only applies to marketplace installs; warn when it would be silently no-op'd for npm.
386
430
  if (flags.scope) {
387
431
  console.error(
@@ -923,6 +967,7 @@ ${chalk.bold("Sources:")}
923
967
  github:user/repo[#ref] GitHub shorthand (also gitlab:, bitbucket:, codeberg:, sourcehut:)
924
968
  https://github.com/user/repo Full git URL (https, ssh, or git protocol)
925
969
  name@marketplace Marketplace plugin (see marketplace command)
970
+ ./path, ../path, /abs, ~/path Local plugin directory (symlinked, same as plugin link)
926
971
 
927
972
  ${chalk.bold("Config Subcommands:")}
928
973
  config list <pkg> List all settings
@@ -97,7 +97,6 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
97
97
  const result = await runSearchQuery(params);
98
98
  const component = renderSearchResult(result, { expanded: cmd.expanded, isPartial: false }, theme, {
99
99
  query: cmd.query,
100
- allowLongAnswer: true,
101
100
  maxAnswerLines: cmd.expanded ? undefined : 6,
102
101
  });
103
102
 
@@ -38,6 +38,45 @@ const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
38
38
  // "socket connection was closed unexpectedly").
39
39
  const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
40
40
 
41
+ const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
42
+ const OLLAMA_HOST_DEFAULT_PORT = "11434";
43
+
44
+ function normalizeOllamaHostEnv(value: string | undefined): string | undefined {
45
+ const trimmed = value?.trim();
46
+ if (!trimmed) return undefined;
47
+ const candidate = trimmed.includes("://")
48
+ ? trimmed
49
+ : trimmed.startsWith("//")
50
+ ? `http:${trimmed}`
51
+ : trimmed.startsWith(":")
52
+ ? `http://127.0.0.1${trimmed}`
53
+ : `http://${trimmed}`;
54
+ try {
55
+ const parsed = new URL(candidate);
56
+ if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
57
+ return undefined;
58
+ }
59
+ if (!parsed.port && parsed.protocol === "http:") {
60
+ parsed.port = OLLAMA_HOST_DEFAULT_PORT;
61
+ }
62
+ return `${parsed.protocol}//${parsed.host}`;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function getImplicitOllamaBaseUrl(): string {
69
+ const baseUrl = Bun.env.OLLAMA_BASE_URL?.trim();
70
+ return baseUrl || normalizeOllamaHostEnv(Bun.env.OLLAMA_HOST) || DEFAULT_OLLAMA_BASE_URL;
71
+ }
72
+
73
+ function getOllamaContextLengthOverride(): number | undefined {
74
+ const value = Bun.env.OLLAMA_CONTEXT_LENGTH?.trim();
75
+ if (!value) return undefined;
76
+ const parsed = Number(value);
77
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
78
+ }
79
+
41
80
  // Anthropic-safe variant of the discovery cap. The Anthropic stream converter
42
81
  // in `packages/ai/src/providers/anthropic.ts` derives the request limit as
43
82
  // `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922
@@ -1220,7 +1259,18 @@ export class ModelRegistry {
1220
1259
  return models;
1221
1260
  }
1222
1261
 
1223
- return models.map(model => (model.api === "openai-completions" ? { ...model, api: "openai-responses" } : model));
1262
+ const contextLengthOverride = getOllamaContextLengthOverride();
1263
+ return models.map(model => {
1264
+ const normalized = model.api === "openai-completions" ? { ...model, api: "openai-responses" as const } : model;
1265
+ if (contextLengthOverride === undefined) {
1266
+ return normalized;
1267
+ }
1268
+ return {
1269
+ ...normalized,
1270
+ contextWindow: contextLengthOverride,
1271
+ maxTokens: Math.min(contextLengthOverride, DISCOVERY_DEFAULT_MAX_TOKENS),
1272
+ };
1273
+ });
1224
1274
  }
1225
1275
 
1226
1276
  #addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
@@ -1229,7 +1279,7 @@ export class ModelRegistry {
1229
1279
  this.#discoverableProviders.push({
1230
1280
  provider: "ollama",
1231
1281
  api: "openai-responses",
1232
- baseUrl: Bun.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434",
1282
+ baseUrl: getImplicitOllamaBaseUrl(),
1233
1283
  discovery: { type: "ollama" },
1234
1284
  optional: true,
1235
1285
  });
@@ -1993,12 +2043,12 @@ export class ModelRegistry {
1993
2043
  }
1994
2044
  }
1995
2045
  #normalizeOllamaBaseUrl(baseUrl?: string): string {
1996
- const raw = baseUrl || "http://127.0.0.1:11434";
2046
+ const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL;
1997
2047
  try {
1998
2048
  const parsed = new URL(raw);
1999
2049
  return `${parsed.protocol}//${parsed.host}`;
2000
2050
  } catch {
2001
- return "http://127.0.0.1:11434";
2051
+ return DEFAULT_OLLAMA_BASE_URL;
2002
2052
  }
2003
2053
  }
2004
2054
 
@@ -635,7 +635,7 @@ export const SETTINGS_SCHEMA = {
635
635
  tab: "appearance",
636
636
  label: "Terminal Hyperlinks",
637
637
  description:
638
- "Wrap file paths in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
638
+ "Wrap paths and URLs in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
639
639
  },
640
640
  },
641
641
  // Display rendering
@@ -722,6 +722,16 @@ export const SETTINGS_SCHEMA = {
722
722
  },
723
723
  },
724
724
 
725
+ includeModelInPrompt: {
726
+ type: "boolean",
727
+ default: true,
728
+ ui: {
729
+ tab: "model",
730
+ label: "Include Model In Prompt",
731
+ description: "Surface the active model identifier in the system prompt so the agent knows which model it is",
732
+ },
733
+ },
734
+
725
735
  // Sampling
726
736
  temperature: {
727
737
  type: "number",
@@ -2483,13 +2493,13 @@ export const SETTINGS_SCHEMA = {
2483
2493
  // Tool Discovery
2484
2494
  "tools.discoveryMode": {
2485
2495
  type: "enum",
2486
- values: ["off", "mcp-only", "all"] as const,
2487
- default: "off",
2496
+ values: ["auto", "off", "mcp-only", "all"] as const,
2497
+ default: "auto",
2488
2498
  ui: {
2489
2499
  tab: "tools",
2490
2500
  label: "Tool Discovery",
2491
2501
  description:
2492
- "Hide tools behind a search tool to save tokens. 'mcp-only' hides MCP tools; 'all' hides all non-essential built-ins too.",
2502
+ "Hide tools behind a search tool to save tokens. 'auto' hides MCP tools once the tool set has more than 40 tools; 'mcp-only' always hides MCP tools; 'all' hides all non-essential built-ins too.",
2493
2503
  },
2494
2504
  },
2495
2505
 
@@ -397,6 +397,78 @@ describe("agent() through eval runtimes", () => {
397
397
  expect(maxInFlight).toBeLessThanOrEqual(2);
398
398
  });
399
399
 
400
+ it("interrupting a Python parallel() fan-out settles the kernel cleanly and preserves session state", async () => {
401
+ using tempDir = TempDir.createSync("@omp-eval-agent-py-interrupt-");
402
+ const settings = Settings.isolated({
403
+ "async.enabled": false,
404
+ "task.isolation.mode": "none",
405
+ "task.enableLsp": true,
406
+ "task.maxConcurrency": 6,
407
+ });
408
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
409
+ mockAgents();
410
+ // Subagents that ignore the abort for far longer than the kernel's SIGINT
411
+ // escalation window. Each kernel worker thread blocks in a synchronous
412
+ // `urllib` bridge call, joined by `parallel()`'s ThreadPoolExecutor exit.
413
+ // The host must respond the instant the cell aborts so the kernel can
414
+ // unwind via KeyboardInterrupt instead of being hard-killed (which used to
415
+ // surface "[kernel] Python kernel shutdown" and lose all session state).
416
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
417
+ await Bun.sleep(9000); // deliberately ignores options.signal
418
+ return singleResult(options, { output: options.assignment ?? "" });
419
+ });
420
+
421
+ // Seed persistent session state and confirm the kernel is reusable.
422
+ const seed = await executePython("PREP_MARKER = 4242", {
423
+ cwd: tempDir.path(),
424
+ sessionId,
425
+ sessionFile,
426
+ kernelMode: "session",
427
+ toolSession: session,
428
+ });
429
+ if (seed.exitCode === undefined && seed.cancelled) {
430
+ expect(seed.output).toBe("");
431
+ return; // kernel unavailable in this environment
432
+ }
433
+ expect(seed.exitCode).toBe(0);
434
+
435
+ const ac = new AbortController();
436
+ // Abort ~1s in, after the worker threads are blocked in their bridge calls.
437
+ setTimeout(() => ac.abort(new Error("external interrupt")), 1000);
438
+
439
+ const start = Date.now();
440
+ const result = await executePython(
441
+ "import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
442
+ {
443
+ cwd: tempDir.path(),
444
+ sessionId,
445
+ sessionFile,
446
+ kernelMode: "session",
447
+ toolSession: session,
448
+ idleTimeoutMs: 60_000,
449
+ signal: ac.signal,
450
+ },
451
+ );
452
+ const elapsed = Date.now() - start;
453
+
454
+ // Cancelled, but cleanly: no hard-kill, settled well within the kernel's 5s
455
+ // SIGINT escalation window rather than ~6s after it.
456
+ expect(result.cancelled).toBe(true);
457
+ expect(result.output).not.toContain("Python kernel shutdown");
458
+ expect(elapsed).toBeLessThan(4000);
459
+
460
+ // The persistent kernel survived the interrupt: prior state is intact.
461
+ const after = await executePython("print(PREP_MARKER)", {
462
+ cwd: tempDir.path(),
463
+ sessionId,
464
+ sessionFile,
465
+ kernelMode: "session",
466
+ toolSession: session,
467
+ });
468
+ expect(after.exitCode).toBe(0);
469
+ expect(after.output.trim()).toBe("4242");
470
+ }, 30_000);
471
+
400
472
  it("streams enriched agent progress through onStatus before the cell finishes", async () => {
401
473
  using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
402
474
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
@@ -30,6 +30,48 @@ interface BridgeServer {
30
30
  const registrations = new Map<string, PyToolBridgeEntry>();
31
31
  let serverPromise: Promise<BridgeServer> | null = null;
32
32
 
33
+ /**
34
+ * Forward a bridge call to {@link callSessionTool}, but resolve the HTTP request
35
+ * the instant the cell's signal aborts instead of waiting for the tool/subagent
36
+ * to fully tear down.
37
+ *
38
+ * The kernel invokes this bridge with a *blocking* `urllib` request from a
39
+ * worker thread (each `agent()` / `tool.*` call). When the cell is interrupted,
40
+ * `parallel()`'s `ThreadPoolExecutor.__exit__` joins those worker threads
41
+ * (`shutdown(wait=True)`), so they cannot unwind until their `urllib` call
42
+ * returns — i.e. until this handler responds. A host-side `agent()` teardown
43
+ * (aborting nested LLM streams + tools across a wide fan-out) routinely exceeds
44
+ * the kernel's SIGINT escalation window, so the kernel was hard-killed and its
45
+ * persistent state lost while the subagents were still winding down. Responding
46
+ * immediately on abort lets the kernel raise through the blocked call and settle
47
+ * cleanly (preserving state); the already-signaled call keeps tearing down in
48
+ * the background, its eventual result/rejection swallowed.
49
+ */
50
+ async function callSessionToolPromptOnAbort(name: string, args: unknown, entry: PyToolBridgeEntry): Promise<unknown> {
51
+ const call = callSessionTool(name, args, {
52
+ session: entry.toolSession,
53
+ signal: entry.signal,
54
+ emitStatus: entry.emitStatus,
55
+ });
56
+ const signal = entry.signal;
57
+ if (!signal) return await call;
58
+ if (signal.aborted) {
59
+ void call.catch(() => {});
60
+ throw new Error(`bridge call ${JSON.stringify(name)} aborted: eval cell was interrupted`);
61
+ }
62
+ const { promise: aborted, reject } = Promise.withResolvers<never>();
63
+ const onAbort = () => reject(new Error(`bridge call ${JSON.stringify(name)} aborted: eval cell was interrupted`));
64
+ signal.addEventListener("abort", onAbort, { once: true });
65
+ try {
66
+ return await Promise.race([call, aborted]);
67
+ } finally {
68
+ signal.removeEventListener("abort", onAbort);
69
+ // `call` may still be settling (subagent teardown after its own abort);
70
+ // swallow its outcome so an abort-won race can't surface as unhandled.
71
+ void call.catch(() => {});
72
+ }
73
+ }
74
+
33
75
  async function startServer(): Promise<BridgeServer> {
34
76
  const token = crypto.randomUUID();
35
77
  const server = Bun.serve({
@@ -66,11 +108,7 @@ async function startServer(): Promise<BridgeServer> {
66
108
  }
67
109
 
68
110
  try {
69
- const value = await callSessionTool(name, body.args, {
70
- session: entry.toolSession,
71
- signal: entry.signal,
72
- emitStatus: entry.emitStatus,
73
- });
111
+ const value = await callSessionToolPromptOnAbort(name, body.args, entry);
74
112
  return Response.json({ ok: true, value });
75
113
  } catch (err) {
76
114
  return Response.json({
@@ -12,6 +12,35 @@ async function getHeadTag(api: CustomCommandAPI): Promise<string | undefined> {
12
12
  }
13
13
  }
14
14
 
15
+ async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
16
+ try {
17
+ return (await git.branch.current(api.cwd)) ?? "HEAD";
18
+ } catch {
19
+ return "HEAD";
20
+ }
21
+ }
22
+
23
+ async function getPushRemote(api: CustomCommandAPI, branch: string): Promise<string | undefined> {
24
+ try {
25
+ return (
26
+ (await git.config.getBranch(api.cwd, branch, "pushRemote")) ??
27
+ (await git.config.getBranch(api.cwd, branch, "remote"))
28
+ );
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ async function getHeadTagContext(api: CustomCommandAPI): Promise<{ branch: string; headTag?: string; remote: string }> {
35
+ const branch = await getCurrentBranch(api);
36
+ const [headTag, pushRemote] = await Promise.all([getHeadTag(api), getPushRemote(api, branch)]);
37
+ return {
38
+ headTag,
39
+ branch,
40
+ remote: pushRemote ?? "origin",
41
+ };
42
+ }
43
+
15
44
  export class GreenCommand implements CustomCommand {
16
45
  name = "green";
17
46
  description = "Generate a prompt to iterate on CI failures until the branch is green";
@@ -19,7 +48,7 @@ export class GreenCommand implements CustomCommand {
19
48
  constructor(private api: CustomCommandAPI) {}
20
49
 
21
50
  async execute(_args: string[], _ctx: HookCommandContext): Promise<string> {
22
- const headTag = await getHeadTag(this.api);
23
- return prompt.render(ciGreenRequestTemplate, { headTag });
51
+ const { headTag, branch, remote } = await getHeadTagContext(this.api);
52
+ return prompt.render(ciGreenRequestTemplate, { headTag, branch, remote });
24
53
  }
25
54
  }