@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
@@ -1,10 +1,6 @@
1
- import { ToolError } from "./tool-errors";
1
+ import { inflateSync, strFromU8 } from "fflate";
2
2
 
3
- let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
4
- async function loadFflate(): Promise<typeof import("fflate")> {
5
- if (!fflateModulePromise) fflateModulePromise = import("fflate");
6
- return fflateModulePromise;
7
- }
3
+ import { ToolError } from "./tool-errors";
8
4
 
9
5
  export type ArchiveFormat = "zip" | "tar" | "tar.gz";
10
6
 
@@ -35,7 +31,11 @@ interface TarStorage {
35
31
 
36
32
  interface ZipStorage {
37
33
  type: "zip";
38
- bytes: Uint8Array;
34
+ archivePath: string;
35
+ compressedSize: number;
36
+ compression: number;
37
+ flags: number;
38
+ localHeaderOffset: number;
39
39
  }
40
40
 
41
41
  type EntryStorage = TarStorage | ZipStorage;
@@ -123,6 +123,321 @@ function getArchiveFormatFromPath(filePath: string): ArchiveFormat | undefined {
123
123
  return undefined;
124
124
  }
125
125
 
126
+ const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
127
+ const ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50;
128
+ const ZIP64_EOCD_SIGNATURE = 0x06064b50;
129
+ const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50;
130
+ const ZIP_EOCD_SIGNATURE = 0x06054b50;
131
+ const ZIP_EOCD_MIN_LENGTH = 22;
132
+ const ZIP_EOCD_MAX_COMMENT_LENGTH = 0xffff;
133
+ const ZIP64_EOCD_LOCATOR_LENGTH = 20;
134
+ const ZIP_STORED_COMPRESSION = 0;
135
+ const ZIP_DEFLATE_COMPRESSION = 8;
136
+ const ZIP_UTF8_FLAG = 0x0800;
137
+ const ZIP_ENCRYPTED_FLAG = 0x0001;
138
+ const ZIP_UINT16_MAX = 0xffff;
139
+ const ZIP_UINT32_MAX = 0xffffffff;
140
+ const ZIP_UINT32_RANGE = 0x100000000;
141
+
142
+ interface ZipCentralDirectoryInfo {
143
+ entries: number;
144
+ offset: number;
145
+ size: number;
146
+ }
147
+
148
+ interface Zip64EntryValues {
149
+ compressedSize: number;
150
+ uncompressedSize: number;
151
+ localHeaderOffset: number;
152
+ diskStart: number;
153
+ }
154
+
155
+ interface Zip64EntryPlaceholders {
156
+ compressedSize: boolean;
157
+ uncompressedSize: boolean;
158
+ localHeaderOffset: boolean;
159
+ diskStart: boolean;
160
+ }
161
+
162
+ function readUInt16LE(bytes: Uint8Array, offset: number): number {
163
+ return bytes[offset]! | (bytes[offset + 1]! << 8);
164
+ }
165
+
166
+ function readUInt32LE(bytes: Uint8Array, offset: number): number {
167
+ return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
168
+ }
169
+
170
+ function readUInt64LEAsNumber(bytes: Uint8Array, offset: number): number {
171
+ const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * ZIP_UINT32_RANGE;
172
+ if (!Number.isSafeInteger(value)) {
173
+ throw new ToolError("ZIP archive uses offsets or sizes too large to read safely");
174
+ }
175
+ return value;
176
+ }
177
+
178
+ async function readZipRange(filePath: string, start: number, end: number): Promise<Uint8Array> {
179
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) {
180
+ throw new ToolError("Invalid ZIP archive range");
181
+ }
182
+
183
+ const bytes = await Bun.file(filePath).slice(start, end).bytes();
184
+ if (bytes.byteLength !== end - start) {
185
+ throw new ToolError("Invalid ZIP archive: truncated data");
186
+ }
187
+ return bytes;
188
+ }
189
+
190
+ function findEndOfCentralDirectory(tail: Uint8Array): number {
191
+ for (let offset = tail.byteLength - ZIP_EOCD_MIN_LENGTH; offset >= 0; offset--) {
192
+ if (readUInt32LE(tail, offset) !== ZIP_EOCD_SIGNATURE) continue;
193
+ const commentLength = readUInt16LE(tail, offset + 20);
194
+ if (offset + ZIP_EOCD_MIN_LENGTH + commentLength === tail.byteLength) return offset;
195
+ }
196
+
197
+ throw new ToolError("Invalid ZIP archive: missing end of central directory");
198
+ }
199
+
200
+ async function readZip64CentralDirectoryInfo(
201
+ filePath: string,
202
+ tail: Uint8Array,
203
+ tailStart: number,
204
+ eocdOffset: number,
205
+ ): Promise<ZipCentralDirectoryInfo | undefined> {
206
+ const locatorOffset = eocdOffset - ZIP64_EOCD_LOCATOR_LENGTH;
207
+ if (locatorOffset < 0) return undefined;
208
+
209
+ const locator =
210
+ locatorOffset >= tailStart
211
+ ? tail.subarray(locatorOffset - tailStart, locatorOffset - tailStart + ZIP64_EOCD_LOCATOR_LENGTH)
212
+ : await readZipRange(filePath, locatorOffset, eocdOffset);
213
+ if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) return undefined;
214
+
215
+ const zip64EocdDisk = readUInt32LE(locator, 4);
216
+ const zip64EocdOffset = readUInt64LEAsNumber(locator, 8);
217
+ const totalDisks = readUInt32LE(locator, 16);
218
+ if (zip64EocdDisk !== 0 || totalDisks > 1) {
219
+ throw new ToolError("Multi-disk ZIP archives are not supported");
220
+ }
221
+
222
+ const record = await readZipRange(filePath, zip64EocdOffset, zip64EocdOffset + 56);
223
+ if (readUInt32LE(record, 0) !== ZIP64_EOCD_SIGNATURE) {
224
+ throw new ToolError("Invalid ZIP archive: missing ZIP64 end of central directory");
225
+ }
226
+ if (readUInt32LE(record, 16) !== 0 || readUInt32LE(record, 20) !== 0) {
227
+ throw new ToolError("Multi-disk ZIP archives are not supported");
228
+ }
229
+
230
+ return {
231
+ entries: readUInt64LEAsNumber(record, 32),
232
+ size: readUInt64LEAsNumber(record, 40),
233
+ offset: readUInt64LEAsNumber(record, 48),
234
+ };
235
+ }
236
+
237
+ async function readZipCentralDirectoryInfo(filePath: string, fileSize: number): Promise<ZipCentralDirectoryInfo> {
238
+ if (fileSize < ZIP_EOCD_MIN_LENGTH) {
239
+ throw new ToolError("Invalid ZIP archive: missing end of central directory");
240
+ }
241
+
242
+ const tailLength = Math.min(fileSize, ZIP_EOCD_MIN_LENGTH + ZIP_EOCD_MAX_COMMENT_LENGTH);
243
+ const tailStart = fileSize - tailLength;
244
+ const tail = await readZipRange(filePath, tailStart, fileSize);
245
+ const eocdIndex = findEndOfCentralDirectory(tail);
246
+ const eocdOffset = tailStart + eocdIndex;
247
+
248
+ if (readUInt16LE(tail, eocdIndex + 4) !== 0 || readUInt16LE(tail, eocdIndex + 6) !== 0) {
249
+ throw new ToolError("Multi-disk ZIP archives are not supported");
250
+ }
251
+
252
+ let entries = readUInt16LE(tail, eocdIndex + 10);
253
+ let size = readUInt32LE(tail, eocdIndex + 12);
254
+ let offset = readUInt32LE(tail, eocdIndex + 16);
255
+ const needsZip64 = entries === ZIP_UINT16_MAX || size === ZIP_UINT32_MAX || offset === ZIP_UINT32_MAX;
256
+ const zip64Info = await readZip64CentralDirectoryInfo(filePath, tail, tailStart, eocdOffset);
257
+ if (zip64Info) {
258
+ ({ entries, size, offset } = zip64Info);
259
+ } else if (needsZip64) {
260
+ throw new ToolError("Invalid ZIP archive: missing ZIP64 central directory metadata");
261
+ }
262
+
263
+ if (offset + size > fileSize) {
264
+ throw new ToolError("Invalid ZIP archive: central directory exceeds file size");
265
+ }
266
+
267
+ return { entries, offset, size };
268
+ }
269
+
270
+ function readZip64EntryValues(
271
+ extra: Uint8Array,
272
+ placeholders: Zip64EntryPlaceholders,
273
+ current: Zip64EntryValues,
274
+ ): Zip64EntryValues {
275
+ if (
276
+ !placeholders.compressedSize &&
277
+ !placeholders.uncompressedSize &&
278
+ !placeholders.localHeaderOffset &&
279
+ !placeholders.diskStart
280
+ ) {
281
+ return current;
282
+ }
283
+
284
+ let offset = 0;
285
+ while (offset + 4 <= extra.byteLength) {
286
+ const headerId = readUInt16LE(extra, offset);
287
+ const dataSize = readUInt16LE(extra, offset + 2);
288
+ const dataStart = offset + 4;
289
+ const dataEnd = dataStart + dataSize;
290
+ if (dataEnd > extra.byteLength) {
291
+ throw new ToolError("Invalid ZIP archive: malformed extra field");
292
+ }
293
+
294
+ if (headerId === 0x0001) {
295
+ let cursor = dataStart;
296
+ let uncompressedSize = current.uncompressedSize;
297
+ let compressedSize = current.compressedSize;
298
+ let localHeaderOffset = current.localHeaderOffset;
299
+ let diskStart = current.diskStart;
300
+
301
+ if (placeholders.uncompressedSize) {
302
+ if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
303
+ uncompressedSize = readUInt64LEAsNumber(extra, cursor);
304
+ cursor += 8;
305
+ }
306
+ if (placeholders.compressedSize) {
307
+ if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
308
+ compressedSize = readUInt64LEAsNumber(extra, cursor);
309
+ cursor += 8;
310
+ }
311
+ if (placeholders.localHeaderOffset) {
312
+ if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
313
+ localHeaderOffset = readUInt64LEAsNumber(extra, cursor);
314
+ cursor += 8;
315
+ }
316
+ if (placeholders.diskStart) {
317
+ if (cursor + 4 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
318
+ diskStart = readUInt32LE(extra, cursor);
319
+ }
320
+
321
+ return { compressedSize, uncompressedSize, localHeaderOffset, diskStart };
322
+ }
323
+
324
+ offset = dataEnd;
325
+ }
326
+
327
+ throw new ToolError("Invalid ZIP archive: missing ZIP64 extra field");
328
+ }
329
+
330
+ function parseZipCentralDirectory(
331
+ filePath: string,
332
+ centralDirectory: Uint8Array,
333
+ expectedEntries: number,
334
+ ): ArchiveIndexEntry[] {
335
+ const entries: ArchiveIndexEntry[] = [];
336
+ let offset = 0;
337
+
338
+ for (let index = 0; index < expectedEntries; index++) {
339
+ if (offset + 46 > centralDirectory.byteLength) {
340
+ throw new ToolError("Invalid ZIP archive: truncated central directory");
341
+ }
342
+ if (readUInt32LE(centralDirectory, offset) !== ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
343
+ throw new ToolError("Invalid ZIP archive: malformed central directory");
344
+ }
345
+
346
+ const flags = readUInt16LE(centralDirectory, offset + 8);
347
+ const compression = readUInt16LE(centralDirectory, offset + 10);
348
+ const compressedSizeRaw = readUInt32LE(centralDirectory, offset + 20);
349
+ const uncompressedSizeRaw = readUInt32LE(centralDirectory, offset + 24);
350
+ const fileNameLength = readUInt16LE(centralDirectory, offset + 28);
351
+ const extraLength = readUInt16LE(centralDirectory, offset + 30);
352
+ const commentLength = readUInt16LE(centralDirectory, offset + 32);
353
+ const diskStartRaw = readUInt16LE(centralDirectory, offset + 34);
354
+ const localHeaderOffsetRaw = readUInt32LE(centralDirectory, offset + 42);
355
+ const nameStart = offset + 46;
356
+ const extraStart = nameStart + fileNameLength;
357
+ const entryEnd = extraStart + extraLength + commentLength;
358
+ if (entryEnd > centralDirectory.byteLength) {
359
+ throw new ToolError("Invalid ZIP archive: truncated central directory entry");
360
+ }
361
+
362
+ const rawPath = strFromU8(centralDirectory.subarray(nameStart, extraStart), (flags & ZIP_UTF8_FLAG) === 0);
363
+ const normalizedPath = normalizeArchiveEntryPath(rawPath);
364
+ if (normalizedPath) {
365
+ const values = readZip64EntryValues(
366
+ centralDirectory.subarray(extraStart, extraStart + extraLength),
367
+ {
368
+ compressedSize: compressedSizeRaw === ZIP_UINT32_MAX,
369
+ uncompressedSize: uncompressedSizeRaw === ZIP_UINT32_MAX,
370
+ localHeaderOffset: localHeaderOffsetRaw === ZIP_UINT32_MAX,
371
+ diskStart: diskStartRaw === ZIP_UINT16_MAX,
372
+ },
373
+ {
374
+ compressedSize: compressedSizeRaw,
375
+ uncompressedSize: uncompressedSizeRaw,
376
+ localHeaderOffset: localHeaderOffsetRaw,
377
+ diskStart: diskStartRaw,
378
+ },
379
+ );
380
+ if (values.diskStart !== 0) {
381
+ throw new ToolError("Multi-disk ZIP archives are not supported");
382
+ }
383
+
384
+ const isDirectory = isArchiveDirectoryName(rawPath);
385
+ entries.push({
386
+ path: normalizedPath,
387
+ isDirectory,
388
+ size: isDirectory ? 0 : values.uncompressedSize,
389
+ storage: isDirectory
390
+ ? undefined
391
+ : {
392
+ type: "zip",
393
+ archivePath: filePath,
394
+ compressedSize: values.compressedSize,
395
+ compression,
396
+ flags,
397
+ localHeaderOffset: values.localHeaderOffset,
398
+ },
399
+ });
400
+ }
401
+
402
+ offset = entryEnd;
403
+ }
404
+
405
+ return entries;
406
+ }
407
+
408
+ async function readZipFileBytes(storage: ZipStorage, uncompressedSize: number): Promise<Uint8Array> {
409
+ if ((storage.flags & ZIP_ENCRYPTED_FLAG) !== 0) {
410
+ throw new ToolError("Encrypted ZIP entries are not supported");
411
+ }
412
+
413
+ const localHeader = await readZipRange(
414
+ storage.archivePath,
415
+ storage.localHeaderOffset,
416
+ storage.localHeaderOffset + 30,
417
+ );
418
+ if (readUInt32LE(localHeader, 0) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
419
+ throw new ToolError("Invalid ZIP archive: malformed local file header");
420
+ }
421
+
422
+ const fileNameLength = readUInt16LE(localHeader, 26);
423
+ const extraLength = readUInt16LE(localHeader, 28);
424
+ const dataStart = storage.localHeaderOffset + 30 + fileNameLength + extraLength;
425
+ const compressedBytes = await readZipRange(storage.archivePath, dataStart, dataStart + storage.compressedSize);
426
+
427
+ if (storage.compression === ZIP_STORED_COMPRESSION) {
428
+ return compressedBytes;
429
+ }
430
+ if (storage.compression !== ZIP_DEFLATE_COMPRESSION) {
431
+ throw new ToolError(`Unsupported ZIP compression method: ${storage.compression}`);
432
+ }
433
+
434
+ try {
435
+ return inflateSync(compressedBytes, { out: new Uint8Array(uncompressedSize) });
436
+ } catch (error) {
437
+ throw new ToolError(error instanceof Error ? error.message : String(error));
438
+ }
439
+ }
440
+
126
441
  async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
127
442
  let archive: Bun.Archive;
128
443
  try {
@@ -155,29 +470,19 @@ async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
155
470
  return entries;
156
471
  }
157
472
 
158
- async function readZipEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
159
- const { unzipSync } = await loadFflate();
160
- let files: Record<string, Uint8Array>;
161
- try {
162
- files = unzipSync(bytes);
163
- } catch (error) {
164
- throw new ToolError(error instanceof Error ? error.message : String(error));
473
+ async function readZipEntries(filePath: string): Promise<ArchiveIndexEntry[]> {
474
+ const fileSize = Bun.file(filePath).size;
475
+ if (!Number.isSafeInteger(fileSize)) {
476
+ throw new ToolError("ZIP archive is too large to read safely");
165
477
  }
166
478
 
167
- const entries: ArchiveIndexEntry[] = [];
168
- for (const [rawPath, fileBytes] of Object.entries(files)) {
169
- const normalizedPath = normalizeArchiveEntryPath(rawPath);
170
- if (!normalizedPath) continue;
171
- const isDirectory = isArchiveDirectoryName(rawPath);
172
- entries.push({
173
- path: normalizedPath,
174
- isDirectory,
175
- size: isDirectory ? 0 : fileBytes.byteLength,
176
- storage: isDirectory ? undefined : { type: "zip", bytes: fileBytes },
177
- });
178
- }
179
-
180
- return entries;
479
+ const directoryInfo = await readZipCentralDirectoryInfo(filePath, fileSize);
480
+ const centralDirectory = await readZipRange(
481
+ filePath,
482
+ directoryInfo.offset,
483
+ directoryInfo.offset + directoryInfo.size,
484
+ );
485
+ return parseZipCentralDirectory(filePath, centralDirectory, directoryInfo.entries);
181
486
  }
182
487
 
183
488
  export function parseArchivePathCandidates(filePath: string): ArchivePathCandidate[] {
@@ -297,7 +602,10 @@ export class ArchiveReader {
297
602
  throw new ToolError(`Archive file '${normalizedPath}' has no readable storage`);
298
603
  }
299
604
 
300
- const bytes = entry.storage.type === "tar" ? await entry.storage.file.bytes() : entry.storage.bytes;
605
+ const bytes =
606
+ entry.storage.type === "tar"
607
+ ? await entry.storage.file.bytes()
608
+ : await readZipFileBytes(entry.storage, entry.size);
301
609
 
302
610
  return {
303
611
  path: entry.path,
@@ -315,7 +623,7 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
315
623
  throw new ToolError(`Unsupported archive format: ${filePath}`);
316
624
  }
317
625
 
318
- const bytes = await Bun.file(filePath).bytes();
319
- const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
626
+ const entries =
627
+ format === "zip" ? await readZipEntries(filePath) : await readTarEntries(await Bun.file(filePath).bytes());
320
628
  return new ArchiveReader(format, entries);
321
629
  }
@@ -13,7 +13,7 @@ import { type Theme, theme } from "../modes/theme/theme";
13
13
  import type { ToolSession } from "../sdk";
14
14
  import type { AgentStorage } from "../session/agent-storage";
15
15
  import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
16
- import { renderStatusLine } from "../tui";
16
+ import { renderStatusLine, urlHyperlink } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
19
19
  import { ensureTool } from "../utils/tools-manager";
@@ -1437,6 +1437,27 @@ function countNonEmptyLines(text: string): number {
1437
1437
  return text.split("\n").filter(l => l.trim()).length;
1438
1438
  }
1439
1439
 
1440
+ function readUrlLinkTarget(input: string): string {
1441
+ try {
1442
+ return parseReadUrlTarget(input)?.path ?? input;
1443
+ } catch {
1444
+ return input;
1445
+ }
1446
+ }
1447
+
1448
+ function formatReadUrlDescription(input: string): string {
1449
+ const target = readUrlLinkTarget(input);
1450
+ const displayUrl = target.match(/^www\./i) ? `https://${target}` : target;
1451
+ const domain = getDomain(displayUrl);
1452
+ const urlPath = truncate(displayUrl.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
1453
+ const label = `${domain}${urlPath ? ` ${urlPath}` : ""}`.trim();
1454
+ return urlHyperlink(target, label);
1455
+ }
1456
+
1457
+ function formatReadUrlMetadataValue(url: string, uiTheme: Theme): string {
1458
+ return urlHyperlink(url, uiTheme.fg("mdLinkUrl", url));
1459
+ }
1460
+
1440
1461
  /** Render URL read call (URL preview) */
1441
1462
  export function renderReadUrlCall(
1442
1463
  args: { path?: string; url?: string; raw?: boolean },
@@ -1444,9 +1465,7 @@ export function renderReadUrlCall(
1444
1465
  uiTheme: Theme = theme,
1445
1466
  ): Component {
1446
1467
  const url = args.path ?? args.url ?? "";
1447
- const domain = getDomain(url);
1448
- const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
1449
- const description = `${domain}${path ? ` ${path}` : ""}`.trim();
1468
+ const description = formatReadUrlDescription(url);
1450
1469
  const meta: string[] = [];
1451
1470
  if (args.raw) meta.push("raw");
1452
1471
  const text = renderStatusLine({ icon: "pending", title: "Read", description, meta }, uiTheme);
@@ -1465,7 +1484,7 @@ export function renderReadUrlResult(
1465
1484
  const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
1466
1485
  const errorText = (rawErrorText || "No response data").replace(/^Error:\s*/, "");
1467
1486
  const urlText = details?.finalUrl ?? details?.url ?? "";
1468
- const description = urlText ? `${getDomain(urlText)}${urlText.replace(/^https?:\/\/[^/]+/, "")}` : undefined;
1487
+ const description = urlText ? formatReadUrlDescription(urlText) : undefined;
1469
1488
  const header = renderStatusLine({ icon: "error", title: "Read", description }, uiTheme);
1470
1489
  const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
1471
1490
  const outputBlock = new CachedOutputBlock();
@@ -1476,8 +1495,7 @@ export function renderReadUrlResult(
1476
1495
  };
1477
1496
  }
1478
1497
 
1479
- const domain = getDomain(details.finalUrl);
1480
- const path = truncate(details.finalUrl.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
1498
+ const description = formatReadUrlDescription(details.finalUrl);
1481
1499
  const hasRedirect = details.url !== details.finalUrl;
1482
1500
  const hasNotes = details.notes.length > 0;
1483
1501
  const truncation = details.meta?.truncation;
@@ -1487,7 +1505,7 @@ export function renderReadUrlResult(
1487
1505
  {
1488
1506
  icon: truncated ? "warning" : "success",
1489
1507
  title: "Read",
1490
- description: `${domain}${path ? ` ${path}` : ""}`,
1508
+ description,
1491
1509
  },
1492
1510
  uiTheme,
1493
1511
  );
@@ -1505,7 +1523,9 @@ export function renderReadUrlResult(
1505
1523
  `${uiTheme.fg("muted", "Method:")} ${details.method}`,
1506
1524
  ];
1507
1525
  if (hasRedirect) {
1508
- metadataLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`);
1526
+ metadataLines.push(
1527
+ `${uiTheme.fg("muted", "Final URL:")} ${formatReadUrlMetadataValue(details.finalUrl, uiTheme)}`,
1528
+ );
1509
1529
  }
1510
1530
  const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`;
1511
1531
  metadataLines.push(`${uiTheme.fg("muted", "Lines:")} ${lineLabel}`);
package/src/tools/gh.ts CHANGED
@@ -792,6 +792,18 @@ function repoFromRepositoryUrl(value: string | undefined): string | undefined {
792
792
  return value.slice(REPO_API_URL_PREFIX.length);
793
793
  }
794
794
 
795
+ function githubRepoSlugEquals(left: string | undefined, right: string): boolean {
796
+ if (left === undefined || left.length !== right.length) return false;
797
+ for (let idx = 0; idx < left.length; idx += 1) {
798
+ let leftCode = left.charCodeAt(idx);
799
+ let rightCode = right.charCodeAt(idx);
800
+ if (leftCode >= 65 && leftCode <= 90) leftCode += 32;
801
+ if (rightCode >= 65 && rightCode <= 90) rightCode += 32;
802
+ if (leftCode !== rightCode) return false;
803
+ }
804
+ return true;
805
+ }
806
+
795
807
  function apiUserToGhUser(user: GhApiUser | null | undefined): GhUser | undefined {
796
808
  if (!user) return undefined;
797
809
  const login = user.login ?? undefined;
@@ -1646,7 +1658,7 @@ async function resolveGitHubRepo(
1646
1658
  runRepo: string | undefined,
1647
1659
  signal?: AbortSignal,
1648
1660
  ): Promise<string> {
1649
- if (repo && runRepo && repo !== runRepo) {
1661
+ if (repo && runRepo && !githubRepoSlugEquals(repo, runRepo)) {
1650
1662
  throw new ToolError("run URL repository does not match the provided repo");
1651
1663
  }
1652
1664
 
@@ -1712,6 +1724,33 @@ export async function resolveDefaultRepoMemoized(cwd: string, signal?: AbortSign
1712
1724
  return untilAborted(signal, pending);
1713
1725
  }
1714
1726
 
1727
+ /**
1728
+ * Best-effort cached cwd → `owner/repo` resolution that swallows any failure
1729
+ * (not a git checkout, no GitHub remote, `gh` unauthenticated, …) into
1730
+ * `undefined`. Use where the cwd repo is a convenience fallback, not a safety
1731
+ * check.
1732
+ */
1733
+ async function tryResolveCurrentRepo(cwd: string, signal: AbortSignal | undefined): Promise<string | undefined> {
1734
+ try {
1735
+ return await resolveDefaultRepoMemoized(cwd, signal);
1736
+ } catch {
1737
+ return undefined;
1738
+ }
1739
+ }
1740
+
1741
+ /**
1742
+ * Best-effort fresh cwd → `owner/repo` resolution for safety checks that must
1743
+ * reflect the repository currently mounted at `cwd`, not the process-lifetime
1744
+ * default-repo cache.
1745
+ */
1746
+ async function tryResolveCurrentRepoFresh(cwd: string, signal: AbortSignal | undefined): Promise<string | undefined> {
1747
+ try {
1748
+ return await resolveGitHubRepo(cwd, undefined, undefined, signal);
1749
+ } catch {
1750
+ return undefined;
1751
+ }
1752
+ }
1753
+
1715
1754
  /**
1716
1755
  * Matches search-query qualifiers that already scope to a repository, org, or
1717
1756
  * user. When present, callers should avoid layering a default `repo:<current>`
@@ -1738,11 +1777,7 @@ async function resolveSearchRepoScope(
1738
1777
  ): Promise<string | undefined> {
1739
1778
  if (repo) return repo;
1740
1779
  if (query && REPO_SCOPE_QUALIFIER_PATTERN.test(query)) return undefined;
1741
- try {
1742
- return await resolveDefaultRepoMemoized(cwd, signal);
1743
- } catch {
1744
- return undefined;
1745
- }
1780
+ return tryResolveCurrentRepo(cwd, signal);
1746
1781
  }
1747
1782
 
1748
1783
  async function resolveGitHubBranchHead(
@@ -3338,8 +3373,9 @@ async function executeRunWatch(
3338
3373
  onUpdate: AgentToolUpdateCallback<GhToolDetails> | undefined,
3339
3374
  ): Promise<AgentToolResult<GhToolDetails>> {
3340
3375
  const branchInput = normalizeOptionalString(params.branch);
3376
+ const explicitRepo = normalizeOptionalString(params.repo);
3341
3377
  const runReference = parseRunReference(params.run);
3342
- const repo = await resolveGitHubRepo(session.cwd, undefined, runReference.repo, signal);
3378
+ const repo = await resolveGitHubRepo(session.cwd, explicitRepo, runReference.repo, signal);
3343
3379
  const intervalSeconds = RUN_WATCH_INTERVAL_DEFAULT;
3344
3380
  const graceSeconds = RUN_WATCH_GRACE_DEFAULT;
3345
3381
  const tail = resolveTailLimit(params.tail);
@@ -3419,10 +3455,28 @@ async function executeRunWatch(
3419
3455
  }
3420
3456
  }
3421
3457
 
3422
- const branch = branchInput ?? (await requireCurrentGitBranch(session.cwd, signal));
3423
- const headSha = branchInput
3424
- ? await resolveGitHubBranchHead(session.cwd, repo, branch, signal)
3425
- : await requireCurrentGitHead(session.cwd, signal);
3458
+ let branch: string;
3459
+ let headSha: string;
3460
+ if (branchInput) {
3461
+ branch = branchInput;
3462
+ headSha = await resolveGitHubBranchHead(session.cwd, repo, branch, signal);
3463
+ } else {
3464
+ // No branch/run selector — derive the commit from the current checkout,
3465
+ // but only when cwd actually points at `repo`. Otherwise we'd watch an
3466
+ // unrelated commit SHA against the explicit repo and silently stream a
3467
+ // confident wrong-repo status (issue #1949). GitHub `owner/repo` slugs
3468
+ // are case-insensitive — `gh repo view` returns the canonical casing
3469
+ // while callers may pass any casing — so the equality check normalizes
3470
+ // both sides before deciding the cwd is a different repo (PR #1951).
3471
+ const cwdRepo = await tryResolveCurrentRepoFresh(session.cwd, signal);
3472
+ if (!githubRepoSlugEquals(cwdRepo, repo)) {
3473
+ throw new ToolError(
3474
+ `Cannot infer the watched commit for ${repo}: current checkout is ${cwdRepo ?? "not a GitHub repository"}. Pass \`branch\` or \`run\` to scope the watch.`,
3475
+ );
3476
+ }
3477
+ branch = await requireCurrentGitBranch(session.cwd, signal);
3478
+ headSha = await requireCurrentGitHead(session.cwd, signal);
3479
+ }
3426
3480
  let pollCount = 0;
3427
3481
  let settledSuccessSignature: string | undefined;
3428
3482
 
@@ -23,6 +23,7 @@ import type { CustomMessage } from "../session/messages";
23
23
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
24
24
  import { TaskTool } from "../task";
25
25
  import type { AgentOutputManager } from "../task/output-manager";
26
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
26
27
  import type { DiscoverableTool, DiscoverableToolSearchIndex } from "../tool-discovery/tool-index";
27
28
  import type { EventBus } from "../utils/event-bus";
28
29
  import { WebSearchTool } from "../web/search";
@@ -420,14 +421,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
420
421
  }
421
422
  }
422
423
  // Resolve effective tool discovery mode.
423
- // tools.discoveryMode takes precedence; mcp.discoveryMode is a back-compat alias for "mcp-only".
424
- const toolsDiscoveryMode = session.settings.get("tools.discoveryMode");
425
- const effectiveDiscoveryMode: "off" | "mcp-only" | "all" =
426
- toolsDiscoveryMode !== "off"
427
- ? (toolsDiscoveryMode as "off" | "mcp-only" | "all")
428
- : session.settings.get("mcp.discoveryMode")
429
- ? "mcp-only"
430
- : "off";
424
+ // tools.discoveryMode controls the new modes; mcp.discoveryMode remains a back-compat alias for "mcp-only".
425
+ const effectiveDiscoveryMode = resolveEffectiveToolDiscoveryMode(
426
+ session.settings,
427
+ countToolsForAutoDiscovery(requestedTools ?? Object.keys(BUILTIN_TOOLS)),
428
+ );
431
429
  const discoveryActive = effectiveDiscoveryMode !== "off";
432
430
 
433
431
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };