@oh-my-pi/pi-coding-agent 14.2.0 → 14.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/package.json +19 -19
  3. package/src/cli/args.ts +10 -1
  4. package/src/cli/shell-cli.ts +15 -3
  5. package/src/config/settings-schema.ts +60 -1
  6. package/src/dap/session.ts +8 -2
  7. package/src/debug/system-info.ts +6 -2
  8. package/src/discovery/claude.ts +58 -36
  9. package/src/discovery/opencode.ts +20 -2
  10. package/src/edit/index.ts +3 -1
  11. package/src/edit/modes/chunk.ts +133 -53
  12. package/src/edit/modes/hashline.ts +36 -11
  13. package/src/edit/renderer.ts +98 -133
  14. package/src/edit/streaming.ts +351 -0
  15. package/src/exec/bash-executor.ts +60 -5
  16. package/src/internal-urls/docs-index.generated.ts +5 -5
  17. package/src/internal-urls/pi-protocol.ts +0 -2
  18. package/src/lsp/client.ts +22 -6
  19. package/src/lsp/defaults.json +2 -1
  20. package/src/lsp/index.ts +53 -10
  21. package/src/lsp/types.ts +2 -0
  22. package/src/modes/acp/acp-agent.ts +76 -2
  23. package/src/modes/components/assistant-message.ts +1 -34
  24. package/src/modes/components/hook-editor.ts +1 -1
  25. package/src/modes/components/tool-execution.ts +111 -101
  26. package/src/modes/controllers/input-controller.ts +1 -1
  27. package/src/modes/interactive-mode.ts +0 -2
  28. package/src/modes/theme/mermaid-cache.ts +13 -52
  29. package/src/modes/theme/theme.ts +2 -2
  30. package/src/prompts/system/system-prompt.md +1 -1
  31. package/src/prompts/tools/ast-grep.md +1 -0
  32. package/src/prompts/tools/browser.md +1 -0
  33. package/src/prompts/tools/chunk-edit.md +25 -22
  34. package/src/prompts/tools/gh-pr-push.md +2 -1
  35. package/src/prompts/tools/grep.md +4 -3
  36. package/src/prompts/tools/lsp.md +6 -0
  37. package/src/prompts/tools/read-chunk.md +46 -7
  38. package/src/prompts/tools/read.md +7 -4
  39. package/src/sdk.ts +8 -5
  40. package/src/session/agent-session.ts +36 -20
  41. package/src/session/session-manager.ts +228 -57
  42. package/src/session/streaming-output.ts +11 -0
  43. package/src/system-prompt.ts +7 -2
  44. package/src/task/executor.ts +1 -0
  45. package/src/tools/ast-edit.ts +37 -2
  46. package/src/tools/bash.ts +75 -12
  47. package/src/tools/find.ts +19 -26
  48. package/src/tools/gh.ts +6 -16
  49. package/src/tools/grep.ts +94 -37
  50. package/src/tools/path-utils.ts +31 -3
  51. package/src/tools/resolve.ts +12 -3
  52. package/src/tools/sqlite-reader.ts +116 -3
  53. package/src/tools/vim.ts +1 -1
  54. package/src/web/search/providers/codex.ts +129 -6
@@ -45,7 +45,6 @@ export class PiProtocolHandler implements ProtocolHandler {
45
45
  content,
46
46
  contentType: "text/markdown",
47
47
  size: Buffer.byteLength(content, "utf-8"),
48
- sourcePath: "pi://",
49
48
  };
50
49
  }
51
50
 
@@ -78,7 +77,6 @@ export class PiProtocolHandler implements ProtocolHandler {
78
77
  content,
79
78
  contentType: "text/markdown",
80
79
  size: Buffer.byteLength(content, "utf-8"),
81
- sourcePath: `pi://${normalized}`,
82
80
  };
83
81
  }
84
82
  }
package/src/lsp/client.ts CHANGED
@@ -211,11 +211,19 @@ async function writeMessage(
211
211
  message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
212
212
  ): Promise<void> {
213
213
  const content = JSON.stringify(message);
214
- sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`);
215
- sink.write(content);
214
+ sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
216
215
  await sink.flush();
217
216
  }
218
217
 
218
+ function queueWriteMessage(
219
+ client: LspClient,
220
+ message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
221
+ ): Promise<void> {
222
+ const write = client.writeQueue.catch(() => {}).then(() => writeMessage(client.proc.stdin, message));
223
+ client.writeQueue = write.catch(() => {});
224
+ return write;
225
+ }
226
+
219
227
  // =============================================================================
220
228
  // Message Reader
221
229
  // =============================================================================
@@ -382,7 +390,7 @@ async function sendResponse(
382
390
  };
383
391
 
384
392
  try {
385
- await writeMessage(client.proc.stdin, response);
393
+ await queueWriteMessage(client, response);
386
394
  } catch (err) {
387
395
  logger.error("LSP failed to respond.", { method, error: String(err) });
388
396
  }
@@ -461,6 +469,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
461
469
  messageBuffer: new Uint8Array(0),
462
470
  isReading: false,
463
471
  lastActivity: Date.now(),
472
+ writeQueue: Promise.resolve(),
464
473
  activeProgressTokens: new Set(),
465
474
  projectLoaded,
466
475
  resolveProjectLoaded,
@@ -475,7 +484,14 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
475
484
 
476
485
  // Reject any pending requests — the server is gone, they will never complete.
477
486
  if (client.pendingRequests.size > 0) {
478
- const stderr = proc.peekStderr().trim();
487
+ // Strip informational log lines (e.g. marksman's [INF]/[DBG] prefix)
488
+ // — they are startup noise, not actionable errors.
489
+ const rawStderr = proc.peekStderr().trim();
490
+ const stderr = rawStderr
491
+ .split("\n")
492
+ .filter(line => !/^\[\d{2}:\d{2}:\d{2} (?:INF|DBG|VRB)\]/.test(line))
493
+ .join("\n")
494
+ .trim();
479
495
  const code = proc.exitCode;
480
496
  const err = new Error(
481
497
  stderr ? `LSP server exited (code ${code}): ${stderr}` : `LSP server exited unexpectedly (code ${code})`,
@@ -848,7 +864,7 @@ export async function sendRequest(
848
864
  });
849
865
 
850
866
  // Write request
851
- writeMessage(client.proc.stdin, request).catch(err => {
867
+ queueWriteMessage(client, request).catch(err => {
852
868
  if (timeout) clearTimeout(timeout);
853
869
  client.pendingRequests.delete(id);
854
870
  cleanup();
@@ -868,7 +884,7 @@ export async function sendNotification(client: LspClient, method: string, params
868
884
  };
869
885
 
870
886
  client.lastActivity = Date.now();
871
- await writeMessage(client.proc.stdin, notification);
887
+ await queueWriteMessage(client, notification);
872
888
  }
873
889
 
874
890
  /**
@@ -857,7 +857,8 @@
857
857
  "rootMarkers": [
858
858
  ".marksman.toml",
859
859
  ".git"
860
- ]
860
+ ],
861
+ "warmupTimeoutMs": 15000
861
862
  },
862
863
  "texlab": {
863
864
  "command": "texlab",
package/src/lsp/index.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  type LspParams,
41
41
  type LspToolDetails,
42
42
  lspSchema,
43
+ type Position,
43
44
  type PublishedDiagnostics,
44
45
  type ServerConfig,
45
46
  type SymbolInformation,
@@ -262,6 +263,10 @@ function getLspServerForFile(config: LspConfig, filePath: string): [string, Serv
262
263
  return servers.length > 0 ? servers[0] : null;
263
264
  }
264
265
 
266
+ function isProjectAwareLspServer(serverConfig: ServerConfig): boolean {
267
+ return !serverConfig.createClient && !serverConfig.isLinter;
268
+ }
269
+
265
270
  const DIAGNOSTIC_MESSAGE_LIMIT = 50;
266
271
  const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
267
272
  const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
@@ -278,6 +283,21 @@ function limitDiagnosticMessages(messages: string[]): string[] {
278
283
  const LOCATION_CONTEXT_LINES = 1;
279
284
  const REFERENCE_CONTEXT_LIMIT = 50;
280
285
 
286
+ const REFERENCES_RETRY_COUNT = 2;
287
+ const REFERENCES_RETRY_DELAY_MS = 250;
288
+
289
+ function comparePosition(a: Position, b: Position): number {
290
+ return a.line === b.line ? a.character - b.character : a.line - b.line;
291
+ }
292
+
293
+ function rangeContainsPosition(range: Location["range"], position: Position): boolean {
294
+ return comparePosition(range.start, position) <= 0 && comparePosition(position, range.end) <= 0;
295
+ }
296
+
297
+ function isOnlyQueriedDeclaration(locations: Location[], uri: string, position: Position): boolean {
298
+ return locations.length === 1 && locations[0]?.uri === uri && rangeContainsPosition(locations[0].range, position);
299
+ }
300
+
281
301
  function normalizeLocationResult(result: Location | Location[] | LocationLink | LocationLink[] | null): Location[] {
282
302
  if (!result) return [];
283
303
  const raw = Array.isArray(result) ? result : [result];
@@ -560,6 +580,10 @@ async function getDiagnosticsForFile(
560
580
  // Default: use LSP
561
581
  const client = await getOrCreateClient(serverConfig, cwd);
562
582
  throwIfAborted(signal);
583
+ if (isProjectAwareLspServer(serverConfig)) {
584
+ await waitForProjectLoaded(client, signal);
585
+ throwIfAborted(signal);
586
+ }
563
587
  // Content already synced + didSave sent, wait for fresh diagnostics
564
588
  const minVersion = minVersions?.get(serverName);
565
589
  const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
@@ -1220,6 +1244,10 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1220
1244
  continue;
1221
1245
  }
1222
1246
  const client = await getOrCreateClient(serverConfig, this.session.cwd);
1247
+ if (isProjectAwareLspServer(serverConfig)) {
1248
+ await waitForProjectLoaded(client, signal);
1249
+ throwIfAborted(signal);
1250
+ }
1223
1251
  const minVersion = client.diagnosticsVersion;
1224
1252
  await refreshFile(client, resolved, signal);
1225
1253
  const expectedDocumentVersion = client.openFiles.get(uri)?.version;
@@ -1512,16 +1540,31 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1512
1540
  break;
1513
1541
  }
1514
1542
  case "references": {
1515
- const result = (await sendRequest(
1516
- client,
1517
- "textDocument/references",
1518
- {
1519
- textDocument: { uri },
1520
- position,
1521
- context: { includeDeclaration: true },
1522
- },
1523
- signal,
1524
- )) as Location[] | null;
1543
+ let result: Location[] | null = null;
1544
+ for (let attempt = 0; attempt <= REFERENCES_RETRY_COUNT; attempt++) {
1545
+ result = (await sendRequest(
1546
+ client,
1547
+ "textDocument/references",
1548
+ {
1549
+ textDocument: { uri },
1550
+ position,
1551
+ context: { includeDeclaration: true },
1552
+ },
1553
+ signal,
1554
+ )) as Location[] | null;
1555
+
1556
+ const locations = result ?? [];
1557
+ if (!isProjectAwareLspServer(serverConfig) || attempt === REFERENCES_RETRY_COUNT) {
1558
+ break;
1559
+ }
1560
+ if (locations.length > 0 && !isOnlyQueriedDeclaration(locations, uri, position)) {
1561
+ break;
1562
+ }
1563
+
1564
+ await waitForProjectLoaded(client, signal);
1565
+ throwIfAborted(signal);
1566
+ await untilAborted(signal, () => Bun.sleep(REFERENCES_RETRY_DELAY_MS));
1567
+ }
1525
1568
 
1526
1569
  if (!result || result.length === 0) {
1527
1570
  output = "No references found";
package/src/lsp/types.ts CHANGED
@@ -411,6 +411,8 @@ export interface LspClient {
411
411
  isReading: boolean;
412
412
  serverCapabilities?: LspServerCapabilities;
413
413
  lastActivity: number;
414
+ /** Serializes outbound JSON-RPC writes to the server process. */
415
+ writeQueue: Promise<void>;
414
416
  /** Tracks active work-done progress tokens from the server */
415
417
  activeProgressTokens: Set<string | number>;
416
418
  /** Resolves when the server's initial project loading completes (or after timeout) */
@@ -39,11 +39,14 @@ import {
39
39
  } from "@agentclientprotocol/sdk";
40
40
  import type { Model } from "@oh-my-pi/pi-ai";
41
41
  import { logger, VERSION } from "@oh-my-pi/pi-utils";
42
+ import { disableProvider, enableProvider } from "../../capability";
43
+ import { Settings } from "../../config/settings";
42
44
  import type { ExtensionUIContext } from "../../extensibility/extensions";
43
45
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
44
46
  import { loadSlashCommands } from "../../extensibility/slash-commands";
45
47
  import { MCPManager } from "../../mcp/manager";
46
48
  import type { MCPServerConfig } from "../../mcp/types";
49
+ import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
47
50
  import { theme } from "../../modes/theme/theme";
48
51
  import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
49
52
  import {
@@ -379,8 +382,79 @@ export class AcpAgent implements Agent {
379
382
  }
380
383
  }
381
384
 
382
- async extMethod(_method: string, _params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> {
383
- throw new Error("ACP extension methods are not implemented");
385
+ async extMethod(method: string, params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> {
386
+ switch (method) {
387
+ case "omp/sessions/listAll": {
388
+ const limit = typeof params.limit === "number" ? Math.max(1, Math.min(5000, params.limit as number)) : 1000;
389
+ const sessions = await SessionManager.listAll();
390
+ const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit);
391
+ return {
392
+ sessions: sorted.map(s => this.#toSessionInfo(s)),
393
+ total: sessions.length,
394
+ };
395
+ }
396
+ case "omp/projects/list": {
397
+ const sessions = await SessionManager.listAll();
398
+ const buckets = new Map<
399
+ string,
400
+ { cwd: string; sessionCount: number; lastActivityAt: number; lastTitle: string }
401
+ >();
402
+ for (const s of sessions) {
403
+ if (!s.cwd) continue;
404
+ const ts = s.modified.getTime();
405
+ const existing = buckets.get(s.cwd);
406
+ if (existing) {
407
+ existing.sessionCount += 1;
408
+ if (ts > existing.lastActivityAt) {
409
+ existing.lastActivityAt = ts;
410
+ existing.lastTitle = s.title ?? "";
411
+ }
412
+ } else {
413
+ buckets.set(s.cwd, {
414
+ cwd: s.cwd,
415
+ sessionCount: 1,
416
+ lastActivityAt: ts,
417
+ lastTitle: s.title ?? "",
418
+ });
419
+ }
420
+ }
421
+ const projects = Array.from(buckets.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
422
+ return { projects, totalSessions: sessions.length };
423
+ }
424
+ case "omp/chats/byCwd": {
425
+ const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined;
426
+ if (!cwd) throw new Error("cwd required");
427
+ const limit = typeof params.limit === "number" ? Math.max(1, Math.min(500, params.limit as number)) : 100;
428
+ const sessions = await SessionManager.list(cwd);
429
+ const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit);
430
+ return { sessions: sorted.map(s => this.#toSessionInfo(s)) };
431
+ }
432
+ case "omp/usage": {
433
+ const [firstRecord] = this.#sessions.values();
434
+ const target = firstRecord?.session ?? this.#initialSession;
435
+ const reports = await target.fetchUsageReports();
436
+ return { reports: reports ?? [] };
437
+ }
438
+ case "omp/extensions": {
439
+ const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined;
440
+ const sm = await Settings.init();
441
+ const disabledIds = (sm.get("disabledExtensions") as string[] | undefined) ?? [];
442
+ const extensions = await loadAllExtensions(cwd, disabledIds);
443
+ return { extensions: extensions as unknown as Array<{ [key: string]: unknown }> };
444
+ }
445
+ case "omp/extensions/toggle": {
446
+ const providerId = params.providerId;
447
+ if (typeof providerId !== "string") throw new Error("providerId required");
448
+ if (params.enabled === false) {
449
+ disableProvider(providerId);
450
+ return { enabled: false };
451
+ }
452
+ enableProvider(providerId);
453
+ return { enabled: true };
454
+ }
455
+ default:
456
+ throw new Error(`Unknown ACP ext method: ${method}`);
457
+ }
384
458
  }
385
459
 
386
460
  async extNotification(_method: string, _params: { [key: string]: unknown }): Promise<void> {}
@@ -1,8 +1,7 @@
1
1
  import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
2
2
  import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
3
- import { formatNumber, logger } from "@oh-my-pi/pi-utils";
3
+ import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
- import { hasPendingMermaid, prerenderMermaid } from "../../modes/theme/mermaid-cache";
6
5
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
6
  import { resolveImageOptions } from "../../tools/render-utils";
8
7
 
@@ -12,7 +11,6 @@ import { resolveImageOptions } from "../../tools/render-utils";
12
11
  export class AssistantMessageComponent extends Container {
13
12
  #contentContainer: Container;
14
13
  #lastMessage?: AssistantMessage;
15
- #prerenderInFlight = false;
16
14
  #toolImagesByCallId = new Map<string, ImageContent[]>();
17
15
  #usageInfo?: Usage;
18
16
 
@@ -85,34 +83,6 @@ export class AssistantMessageComponent extends Container {
85
83
  this.#contentContainer.addChild(new Text(theme.fg("toolOutput", `[Image: ${image.mimeType}]`), 1, 0));
86
84
  }
87
85
  }
88
- #triggerMermaidPrerender(message: AssistantMessage): void {
89
- if (!TERMINAL.imageProtocol || this.#prerenderInFlight) return;
90
-
91
- // Check if any text content has pending mermaid blocks
92
- const hasPending = message.content.some(c => c.type === "text" && c.text.trim() && hasPendingMermaid(c.text));
93
- if (!hasPending) return;
94
-
95
- this.#prerenderInFlight = true;
96
-
97
- // Fire off background prerender
98
- void (async () => {
99
- try {
100
- for (const content of message.content) {
101
- if (content.type === "text" && content.text.trim() && hasPendingMermaid(content.text)) {
102
- prerenderMermaid(content.text);
103
- }
104
- }
105
- } catch (error) {
106
- logger.warn("Background mermaid prerender failed", {
107
- error: error instanceof Error ? error.message : String(error),
108
- });
109
- } finally {
110
- this.#prerenderInFlight = false;
111
- // Invalidate to re-render with cached images
112
- this.invalidate();
113
- }
114
- })();
115
- }
116
86
 
117
87
  updateContent(message: AssistantMessage): void {
118
88
  this.#lastMessage = message;
@@ -120,9 +90,6 @@ export class AssistantMessageComponent extends Container {
120
90
  // Clear content container
121
91
  this.#contentContainer.clear();
122
92
 
123
- // Trigger background mermaid pre-rendering if needed
124
- this.#triggerMermaidPrerender(message);
125
-
126
93
  const hasVisibleContent = message.content.some(
127
94
  c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
128
95
  );
@@ -136,7 +136,7 @@ export class HookEditorComponent extends Container {
136
136
  const editorCmd = getEditorCommand();
137
137
  if (!editorCmd) return;
138
138
 
139
- const currentText = this.#editor.getText();
139
+ const currentText = this.#editor.getExpandedText();
140
140
  try {
141
141
  this.#tui.stop();
142
142
  const result = await openInEditor(editorCmd, currentText);