@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
@@ -193,6 +193,7 @@ export interface ResolvedMultiSearchPath {
193
193
  basePath: string;
194
194
  glob?: string;
195
195
  scopePath: string;
196
+ exactFilePaths?: string[];
196
197
  }
197
198
 
198
199
  export interface ResolvedMultiFindPattern {
@@ -438,6 +439,28 @@ async function areDelimitedTokensResolvable(
438
439
  return true;
439
440
  }
440
441
 
442
+ async function filterResolvableTokens(
443
+ tokens: string[],
444
+ cwd: string,
445
+ parseBasePath: (value: string) => string,
446
+ ): Promise<string[]> {
447
+ const out: string[] = [];
448
+ for (const token of tokens) {
449
+ if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) continue;
450
+ const basePath = parseBasePath(token);
451
+ const resolvedBasePath = resolveToCwd(basePath, cwd);
452
+ if (await pathExists(resolvedBasePath)) {
453
+ out.push(token);
454
+ continue;
455
+ }
456
+ const resolvedExactPath = resolveToCwd(token, cwd);
457
+ if (await pathExists(resolvedExactPath)) {
458
+ out.push(token);
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+
441
464
  async function splitDelimitedSearchInput(
442
465
  rawInput: string,
443
466
  cwd: string,
@@ -452,8 +475,11 @@ async function splitDelimitedSearchInput(
452
475
  }
453
476
 
454
477
  const commaSeparated = splitTopLevel(trimmed, "comma");
455
- if (commaSeparated.length > 1 && (await areDelimitedTokensResolvable(commaSeparated, cwd, parseBasePath, true))) {
456
- return [...new Set(commaSeparated)];
478
+ if (commaSeparated.length > 1) {
479
+ const resolvable = await filterResolvableTokens(commaSeparated, cwd, parseBasePath);
480
+ if (resolvable.length >= 1) {
481
+ return [...new Set(resolvable)];
482
+ }
457
483
  }
458
484
 
459
485
  const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
@@ -473,7 +499,7 @@ export async function resolveMultiSearchPath(
473
499
  suffixGlob?: string,
474
500
  ): Promise<ResolvedMultiSearchPath | undefined> {
475
501
  const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
476
- if (!pathItems || pathItems.length <= 1) {
502
+ if (!pathItems || pathItems.length < 1) {
477
503
  return undefined;
478
504
  }
479
505
 
@@ -486,6 +512,7 @@ export async function resolveMultiSearchPath(
486
512
  }),
487
513
  );
488
514
 
515
+ const allExactFiles = !suffixGlob && parsedItems.every(item => !item.parsedPath.glob && item.stat.isFile());
489
516
  const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
490
517
  const combinedPatterns = parsedItems.map(item => {
491
518
  const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
@@ -507,6 +534,7 @@ export async function resolveMultiSearchPath(
507
534
  basePath: commonBasePath,
508
535
  glob: buildBraceUnion(combinedPatterns),
509
536
  scopePath: toScopeDisplay(pathItems),
537
+ exactFilePaths: allExactFiles ? parsedItems.map(item => item.absoluteBasePath) : undefined,
510
538
  };
511
539
  }
512
540
 
@@ -23,6 +23,7 @@ export interface ResolveToolDetails {
23
23
  reason: string;
24
24
  sourceToolName?: string;
25
25
  label?: string;
26
+ sourceResultDetails?: unknown;
26
27
  }
27
28
 
28
29
  function resolveReasonPreview(reason?: string): string | undefined {
@@ -67,14 +68,21 @@ export function queueResolveHandler(
67
68
  onRejected: () => "requeue",
68
69
  onInvoked: async (input: unknown) => {
69
70
  const params = input as ResolveParams;
71
+ const withResolveDetails = (result: AgentToolResult<unknown>): AgentToolResult<ResolveToolDetails> => ({
72
+ ...result,
73
+ details: {
74
+ ...detailsFor(params),
75
+ ...(result.details != null ? { sourceResultDetails: result.details } : {}),
76
+ },
77
+ });
70
78
  if (params.action === "apply") {
71
79
  const result = await options.apply(params.reason);
72
- return { ...result, details: detailsFor(params) };
80
+ return withResolveDetails(result);
73
81
  }
74
82
  if (params.action === "discard" && options.reject != null) {
75
83
  const result = await options.reject(params.reason);
76
84
  if (result != null) {
77
- return { ...result, details: detailsFor(params) };
85
+ return withResolveDetails(result);
78
86
  }
79
87
  }
80
88
  return {
@@ -154,9 +162,10 @@ export const resolveToolRenderer = {
154
162
  const reason = replaceTabs(details?.reason?.trim() || "No reason provided");
155
163
  const action = details?.action ?? "apply";
156
164
  const isApply = action === "apply" && !result.isError;
165
+ const isFailedApply = action === "apply" && result.isError;
157
166
  const bgColor = result.isError ? "error" : isApply ? "success" : "warning";
158
167
  const icon = isApply ? uiTheme.status.success : uiTheme.status.error;
159
- const verb = isApply ? "Accept" : "Discard";
168
+ const verb = isApply ? "Accept" : isFailedApply ? "Failed" : "Discard";
160
169
  const separator = ": ";
161
170
  const separatorIndex = label.indexOf(separator);
162
171
  const sourceLabel = separatorIndex > 0 ? label.slice(0, separatorIndex).trim() : undefined;
@@ -252,6 +252,112 @@ function resolveOrderClause(order: string | undefined, columns: string[]): strin
252
252
  return ` ORDER BY ${quoteSqliteIdentifier(column)} ${direction.toUpperCase()}`;
253
253
  }
254
254
 
255
+ const FORBIDDEN_WHERE_KEYWORDS = new Set([
256
+ "limit",
257
+ "offset",
258
+ "union",
259
+ "intersect",
260
+ "except",
261
+ "attach",
262
+ "detach",
263
+ "pragma",
264
+ ]);
265
+
266
+ const COMMENT_OR_TERMINATOR_ERROR =
267
+ "SQLite 'where' clause must not contain comments or statement terminators; use '?q=SELECT ...' for raw SQL";
268
+ const FORBIDDEN_KEYWORD_ERROR =
269
+ "SQLite 'where' clause must not contain LIMIT/OFFSET/UNION/INTERSECT/EXCEPT/ATTACH/DETACH/PRAGMA; use '?q=SELECT ...' for raw SQL";
270
+
271
+ /**
272
+ * Scans a `where=` clause character-by-character, tracking single- and double-quoted
273
+ * string literals, and rejects SQL control syntax that would otherwise let the
274
+ * structured helper path escape the bound `LIMIT ? OFFSET ?` pagination:
275
+ *
276
+ * - comments (`--`, `/* ... *\/`) and statement terminators (`;`) outside quotes
277
+ * - pagination / attach / pragma keywords outside quotes
278
+ *
279
+ * Raw SQL remains available through `?q=SELECT ...`.
280
+ */
281
+ function findWhereClauseViolation(sql: string): string | null {
282
+ let inSingleQuote = false;
283
+ let inDoubleQuote = false;
284
+ let tokenStart = -1;
285
+ let keywordViolation: string | null = null;
286
+
287
+ const flushToken = (end: number): void => {
288
+ if (tokenStart < 0 || keywordViolation) {
289
+ tokenStart = -1;
290
+ return;
291
+ }
292
+ const token = sql.slice(tokenStart, end).toLowerCase();
293
+ tokenStart = -1;
294
+ if (FORBIDDEN_WHERE_KEYWORDS.has(token)) {
295
+ keywordViolation = FORBIDDEN_KEYWORD_ERROR;
296
+ }
297
+ };
298
+
299
+ for (let index = 0; index <= sql.length; index++) {
300
+ const char = index < sql.length ? sql[index] : undefined;
301
+ const next = index + 1 < sql.length ? sql[index + 1] : undefined;
302
+
303
+ if (inSingleQuote) {
304
+ if (char === "'" && next === "'") {
305
+ index += 1;
306
+ continue;
307
+ }
308
+ if (char === "'") {
309
+ inSingleQuote = false;
310
+ }
311
+ continue;
312
+ }
313
+ if (inDoubleQuote) {
314
+ if (char === '"' && next === '"') {
315
+ index += 1;
316
+ continue;
317
+ }
318
+ if (char === '"') {
319
+ inDoubleQuote = false;
320
+ }
321
+ continue;
322
+ }
323
+
324
+ const isIdent = char !== undefined && /[A-Za-z0-9_]/.test(char);
325
+ if (isIdent) {
326
+ if (tokenStart < 0) tokenStart = index;
327
+ continue;
328
+ }
329
+
330
+ flushToken(index);
331
+
332
+ if (char === undefined) break;
333
+ if (char === "'") {
334
+ inSingleQuote = true;
335
+ continue;
336
+ }
337
+ if (char === '"') {
338
+ inDoubleQuote = true;
339
+ continue;
340
+ }
341
+ if (char === ";") return COMMENT_OR_TERMINATOR_ERROR;
342
+ if ((char === "-" && next === "-") || (char === "/" && next === "*") || (char === "*" && next === "/")) {
343
+ return COMMENT_OR_TERMINATOR_ERROR;
344
+ }
345
+ }
346
+
347
+ return keywordViolation;
348
+ }
349
+
350
+ function validateWhereClause(where: string | undefined): string | undefined {
351
+ if (!where) return undefined;
352
+ const trimmed = where.trim();
353
+ if (!trimmed) return undefined;
354
+ const violation = findWhereClauseViolation(trimmed);
355
+ if (violation) {
356
+ throw new ToolError(violation);
357
+ }
358
+ return trimmed;
359
+ }
360
+
255
361
  function normalizeWriteValue(value: unknown, column: string): SqliteBinding {
256
362
  if (value === null) return null;
257
363
  if (
@@ -360,7 +466,7 @@ export function parseSqliteSelector(subPath: string, queryString: string): Sqlit
360
466
  return { kind: "row", table, key };
361
467
  }
362
468
 
363
- const where = params.get("where")?.trim() || undefined;
469
+ const where = validateWhereClause(params.get("where") ?? undefined);
364
470
  const order = params.get("order")?.trim() || undefined;
365
471
  const hasQueryParams = params.has("limit") || params.has("offset") || order !== undefined || where !== undefined;
366
472
  if (hasQueryParams) {
@@ -448,12 +554,19 @@ export function queryRows(
448
554
  opts: { limit: number; offset: number; order?: string; where?: string },
449
555
  ): { columns: string[]; rows: Record<string, unknown>[]; totalCount: number } {
450
556
  const columns = getTableColumns(db, table);
451
- const whereClause = opts.where?.trim() ? ` WHERE ${opts.where.trim()}` : "";
557
+ const validatedWhere = validateWhereClause(opts.where);
558
+ const whereClause = validatedWhere ? ` WHERE ${validatedWhere}` : "";
452
559
  const orderClause = resolveOrderClause(opts.order, columns);
453
560
  const countSql = `SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(table)}${whereClause}`;
454
561
  const selectSql = `SELECT * FROM ${quoteSqliteIdentifier(table)}${whereClause}${orderClause} LIMIT ? OFFSET ?`;
455
562
  const totalCount = db.prepare<SqliteCountRow, []>(countSql).get()?.count ?? 0;
456
- const rows = db.prepare<SqliteRow, SQLQueryBindings[]>(selectSql).all(opts.limit, opts.offset);
563
+ const statement = db.prepare<SqliteRow, SQLQueryBindings[]>(selectSql);
564
+ if (statement.paramsCount !== 2) {
565
+ throw new ToolError(
566
+ "SQLite where clause changed the expected pagination parameters; use q=SELECT ... for raw SQL",
567
+ );
568
+ }
569
+ const rows = statement.all(opts.limit, opts.offset);
457
570
  return { columns, rows, totalCount };
458
571
  }
459
572
 
package/src/tools/vim.ts CHANGED
@@ -639,7 +639,7 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
639
639
 
640
640
  await executeVimSteps(engine, steps, {
641
641
  pauseLastStep: params.pause === true,
642
- onKbdStep: emitUpdate ? () => emitUpdate() : undefined,
642
+ onKbdStep: emitUpdate ? () => emitUpdate(true) : undefined,
643
643
  onInsertStep: emitUpdate ? () => emitUpdate(true) : undefined,
644
644
  });
645
645
 
@@ -106,6 +106,125 @@ function isImagePlaceholderAnswer(text: string): boolean {
106
106
  return text.trim().toLowerCase() === "(see attached image)";
107
107
  }
108
108
 
109
+ function addSource(sources: SearchSource[], source: SearchSource): void {
110
+ if (!sources.some(existing => existing.url === source.url)) {
111
+ sources.push(source);
112
+ }
113
+ }
114
+
115
+ function countCharacter(text: string, target: string): number {
116
+ let count = 0;
117
+ for (const char of text) {
118
+ if (char === target) {
119
+ count += 1;
120
+ }
121
+ }
122
+ return count;
123
+ }
124
+
125
+ /**
126
+ * Strips prose punctuation and unmatched closing delimiters from extracted URLs.
127
+ * Codex often returns links in markdown or sentence text without structured annotations.
128
+ */
129
+ function normalizeExtractedUrl(candidate: string): string | null {
130
+ let url = candidate.trim();
131
+
132
+ while (url.length > 0) {
133
+ const lastCharacter = url.at(-1);
134
+ if (!lastCharacter) break;
135
+ if (/[.,!?;:'"]/u.test(lastCharacter)) {
136
+ url = url.slice(0, -1);
137
+ continue;
138
+ }
139
+ if (lastCharacter === ")" && countCharacter(url, ")") > countCharacter(url, "(")) {
140
+ url = url.slice(0, -1);
141
+ continue;
142
+ }
143
+ if (lastCharacter === "]" && countCharacter(url, "]") > countCharacter(url, "[")) {
144
+ url = url.slice(0, -1);
145
+ continue;
146
+ }
147
+ if (lastCharacter === "}" && countCharacter(url, "}") > countCharacter(url, "{")) {
148
+ url = url.slice(0, -1);
149
+ continue;
150
+ }
151
+ break;
152
+ }
153
+
154
+ if (!/^https?:\/\//.test(url)) {
155
+ return null;
156
+ }
157
+
158
+ try {
159
+ return new URL(url).toString();
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ function findMarkdownLinkUrlEnd(text: string, openParenIndex: number): number | null {
166
+ let depth = 0;
167
+
168
+ for (let index = openParenIndex; index < text.length; index += 1) {
169
+ const character = text[index];
170
+ if (!character || character === "\n") {
171
+ return null;
172
+ }
173
+ if (character === "(") {
174
+ depth += 1;
175
+ continue;
176
+ }
177
+ if (character !== ")") {
178
+ continue;
179
+ }
180
+ depth -= 1;
181
+ if (depth === 0) {
182
+ return index;
183
+ }
184
+ if (depth < 0) {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Extracts citation sources from markdown links and bare URLs in the answer text.
194
+ * Used as a fallback when the Codex response omits `url_citation` annotations.
195
+ */
196
+ function extractTextSources(text: string): SearchSource[] {
197
+ const sources: SearchSource[] = [];
198
+
199
+ for (let index = 0; index < text.length; index += 1) {
200
+ if (text[index] !== "[") {
201
+ continue;
202
+ }
203
+ const titleEnd = text.indexOf("]", index + 1);
204
+ if (titleEnd === -1 || text[titleEnd + 1] !== "(") {
205
+ continue;
206
+ }
207
+ const urlEnd = findMarkdownLinkUrlEnd(text, titleEnd + 1);
208
+ if (urlEnd === null) {
209
+ continue;
210
+ }
211
+ const title = text.slice(index + 1, titleEnd).trim();
212
+ const url = normalizeExtractedUrl(text.slice(titleEnd + 2, urlEnd));
213
+ if (url) {
214
+ addSource(sources, { title: title || url, url });
215
+ }
216
+ index = urlEnd;
217
+ }
218
+
219
+ for (const match of text.matchAll(/https?:\/\/\S+/g)) {
220
+ const url = normalizeExtractedUrl(match[0] ?? "");
221
+ if (!url) continue;
222
+ addSource(sources, { title: url, url });
223
+ }
224
+
225
+ return sources;
226
+ }
227
+
109
228
  /**
110
229
  * Extracts account ID from a Codex access token.
111
230
  * @param accessToken - JWT access token
@@ -211,6 +330,7 @@ async function callCodexSearch(
211
330
  search_context_size: options.searchContextSize ?? "high",
212
331
  },
213
332
  ],
333
+ tool_choice: { type: "web_search" },
214
334
  instructions: options.systemPrompt ?? DEFAULT_INSTRUCTIONS,
215
335
  };
216
336
 
@@ -262,12 +382,7 @@ async function callCodexSearch(
262
382
  for (const annotation of part.annotations) {
263
383
  if (annotation.type === "url_citation" && annotation.url) {
264
384
  // Deduplicate by URL
265
- if (!sources.some(s => s.url === annotation.url)) {
266
- sources.push({
267
- title: annotation.title ?? annotation.url,
268
- url: annotation.url,
269
- });
270
- }
385
+ addSource(sources, { title: annotation.title ?? annotation.url, url: annotation.url });
271
386
  }
272
387
  }
273
388
  }
@@ -317,6 +432,14 @@ async function callCodexSearch(
317
432
  ? streamedAnswer
318
433
  : finalAnswer;
319
434
 
435
+ // Fallback: when Codex omits url_citation annotations, scrape markdown links
436
+ // and bare URLs from the synthesized answer so callers still receive sources.
437
+ if (sources.length === 0 && answer.length > 0) {
438
+ for (const source of extractTextSources(answer)) {
439
+ addSource(sources, source);
440
+ }
441
+ }
442
+
320
443
  return {
321
444
  answer,
322
445
  sources,