@oh-my-pi/pi-coding-agent 16.2.3 → 16.2.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 (49) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +3449 -3438
  3. package/dist/types/cli/update-cli.d.ts +15 -0
  4. package/dist/types/collab/replication-shrink.d.ts +39 -0
  5. package/dist/types/config/provider-globals.d.ts +7 -0
  6. package/dist/types/memories/index.d.ts +20 -1
  7. package/dist/types/modes/components/status-line/component.d.ts +5 -0
  8. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  9. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  10. package/dist/types/modes/interactive-mode.d.ts +1 -0
  11. package/dist/types/modes/theme/theme.d.ts +2 -1
  12. package/dist/types/modes/types.d.ts +2 -1
  13. package/dist/types/session/agent-session.d.ts +7 -0
  14. package/dist/types/session/session-manager.d.ts +2 -3
  15. package/dist/types/task/provider-concurrency.d.ts +40 -0
  16. package/dist/types/utils/git.d.ts +11 -0
  17. package/package.json +12 -12
  18. package/src/autolearn/controller.ts +13 -22
  19. package/src/cli/update-cli.ts +254 -0
  20. package/src/cli/worktree-cli.ts +8 -5
  21. package/src/collab/host.ts +13 -8
  22. package/src/collab/replication-shrink.ts +111 -0
  23. package/src/config/model-discovery.ts +33 -13
  24. package/src/config/provider-globals.ts +25 -0
  25. package/src/config/settings-schema.ts +5 -2
  26. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  27. package/src/memories/index.ts +115 -9
  28. package/src/memory-backend/local-backend.ts +5 -3
  29. package/src/modes/components/status-line/component.ts +35 -2
  30. package/src/modes/components/status-line/segments.ts +22 -8
  31. package/src/modes/components/status-line/types.ts +7 -0
  32. package/src/modes/controllers/command-controller.ts +5 -35
  33. package/src/modes/controllers/event-controller.ts +7 -1
  34. package/src/modes/controllers/input-controller.ts +1 -1
  35. package/src/modes/interactive-mode.ts +9 -20
  36. package/src/modes/theme/theme.ts +6 -0
  37. package/src/modes/types.ts +2 -1
  38. package/src/prompts/goals/goal-todo-context.md +12 -0
  39. package/src/sdk.ts +13 -5
  40. package/src/session/agent-session.ts +129 -14
  41. package/src/session/session-manager.ts +2 -3
  42. package/src/slash-commands/builtin-registry.ts +7 -21
  43. package/src/task/executor.ts +4 -62
  44. package/src/task/provider-concurrency.ts +100 -0
  45. package/src/task/worktree.ts +13 -4
  46. package/src/task/yield-assembly.ts +27 -39
  47. package/src/tools/read.ts +11 -1
  48. package/src/utils/git.ts +13 -0
  49. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -272,6 +272,252 @@ function compareVersions(a: string, b: string): number {
272
272
  return 0;
273
273
  }
274
274
 
275
+ interface BunInstallCachePruneResult {
276
+ scannedPackages: number;
277
+ removedEntries: number;
278
+ }
279
+
280
+ interface BunCachePackageGroup {
281
+ actualDirs: Map<string, string[]>;
282
+ markerDir?: string;
283
+ markerEntries: Map<string, string[]>;
284
+ }
285
+
286
+ function stripBunCacheVersionSuffix(name: string): string {
287
+ const metadataIndex = name.indexOf("@@");
288
+ return metadataIndex === -1 ? name : name.slice(0, metadataIndex);
289
+ }
290
+
291
+ function compareSemverIdentifier(a: string, b: string): number {
292
+ const aNumber = /^\d+$/.test(a);
293
+ const bNumber = /^\d+$/.test(b);
294
+ if (aNumber && bNumber) return Number(a) - Number(b);
295
+ if (aNumber) return -1;
296
+ if (bNumber) return 1;
297
+ return a.localeCompare(b);
298
+ }
299
+
300
+ function compareSemverLikeVersions(a: string, b: string): number {
301
+ const [aCoreWithPrerelease] = a.split("+", 1);
302
+ const [bCoreWithPrerelease] = b.split("+", 1);
303
+ const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2);
304
+ const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2);
305
+ const aParts = aCore.split(".");
306
+ const bParts = bCore.split(".");
307
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
308
+ const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0);
309
+ if (diff !== 0 && Number.isFinite(diff)) return diff;
310
+ }
311
+ if (!aPrerelease && !bPrerelease) return 0;
312
+ if (!aPrerelease) return 1;
313
+ if (!bPrerelease) return -1;
314
+ const aPrereleaseParts = aPrerelease.split(".");
315
+ const bPrereleaseParts = bPrerelease.split(".");
316
+ for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) {
317
+ const aPart = aPrereleaseParts[i];
318
+ const bPart = bPrereleaseParts[i];
319
+ if (aPart === undefined) return -1;
320
+ if (bPart === undefined) return 1;
321
+ const diff = compareSemverIdentifier(aPart, bPart);
322
+ if (diff !== 0) return diff;
323
+ }
324
+ return 0;
325
+ }
326
+
327
+ async function readdirIfExists(dir: string): Promise<fs.Dirent[]> {
328
+ try {
329
+ return await fs.promises.readdir(dir, { withFileTypes: true });
330
+ } catch (err) {
331
+ if (isEnoent(err)) return [];
332
+ throw err;
333
+ }
334
+ }
335
+
336
+ function getBunCacheGroup(groups: Map<string, BunCachePackageGroup>, packageName: string): BunCachePackageGroup {
337
+ let group = groups.get(packageName);
338
+ if (!group) {
339
+ group = { actualDirs: new Map(), markerEntries: new Map() };
340
+ groups.set(packageName, group);
341
+ }
342
+ return group;
343
+ }
344
+
345
+ function addVersionPath(entries: Map<string, string[]>, version: string, entryPath: string): void {
346
+ const paths = entries.get(version);
347
+ if (paths) {
348
+ paths.push(entryPath);
349
+ return;
350
+ }
351
+ entries.set(version, [entryPath]);
352
+ }
353
+
354
+ async function addBunCacheActualDir(
355
+ groups: Map<string, BunCachePackageGroup>,
356
+ dirPath: string,
357
+ packageNames: Set<string> | undefined,
358
+ ): Promise<void> {
359
+ try {
360
+ const manifest = (await Bun.file(path.join(dirPath, "package.json")).json()) as Partial<
361
+ Record<"name" | "version", unknown>
362
+ >;
363
+ if (typeof manifest.name !== "string" || typeof manifest.version !== "string") return;
364
+ if (packageNames && !packageNames.has(manifest.name)) return;
365
+ const group = getBunCacheGroup(groups, manifest.name);
366
+ addVersionPath(group.actualDirs, manifest.version, dirPath);
367
+ } catch (err) {
368
+ if (isEnoent(err)) return;
369
+ throw err;
370
+ }
371
+ }
372
+
373
+ async function addBunCacheMarkerDir(
374
+ groups: Map<string, BunCachePackageGroup>,
375
+ packageName: string,
376
+ markerDir: string,
377
+ packageNames: Set<string> | undefined,
378
+ ): Promise<void> {
379
+ if (packageNames && !packageNames.has(packageName)) return;
380
+ const markerEntries = await readdirIfExists(markerDir);
381
+ const group = getBunCacheGroup(groups, packageName);
382
+ group.markerDir = markerDir;
383
+ for (const entry of markerEntries) {
384
+ const cacheVersion = stripBunCacheVersionSuffix(entry.name);
385
+ addVersionPath(group.markerEntries, cacheVersion, path.join(markerDir, entry.name));
386
+ }
387
+ }
388
+
389
+ async function collectBunCacheGroups(
390
+ cacheDir: string,
391
+ packageNames: Set<string> | undefined,
392
+ ): Promise<Map<string, BunCachePackageGroup>> {
393
+ const groups = new Map<string, BunCachePackageGroup>();
394
+ for (const entry of await readdirIfExists(cacheDir)) {
395
+ if (!entry.isDirectory()) continue;
396
+ const entryPath = path.join(cacheDir, entry.name);
397
+ if (entry.name.startsWith("@")) {
398
+ for (const scopedEntry of await readdirIfExists(entryPath)) {
399
+ if (!scopedEntry.isDirectory()) continue;
400
+ const scopedEntryPath = path.join(entryPath, scopedEntry.name);
401
+ const versionSeparator = scopedEntry.name.lastIndexOf("@");
402
+ if (versionSeparator === -1) {
403
+ await addBunCacheMarkerDir(groups, `${entry.name}/${scopedEntry.name}`, scopedEntryPath, packageNames);
404
+ } else {
405
+ await addBunCacheActualDir(groups, scopedEntryPath, packageNames);
406
+ }
407
+ }
408
+ continue;
409
+ }
410
+ const versionSeparator = entry.name.lastIndexOf("@");
411
+ if (versionSeparator === -1) {
412
+ await addBunCacheMarkerDir(groups, entry.name, entryPath, packageNames);
413
+ } else {
414
+ await addBunCacheActualDir(groups, entryPath, packageNames);
415
+ }
416
+ }
417
+ return groups;
418
+ }
419
+
420
+ async function removeCacheEntries(paths: string[]): Promise<number> {
421
+ for (const entryPath of paths) {
422
+ await fs.promises.rm(entryPath, { recursive: true, force: true });
423
+ }
424
+ return paths.length;
425
+ }
426
+
427
+ /**
428
+ * Prune Bun's package cache so each package keeps only its newest cached version.
429
+ *
430
+ * Bun stores package cache entries as both a package marker directory
431
+ * (`react/19.2.6@@@1`) and a materialized package directory
432
+ * (`react@19.2.6@@@1`). Global `omp` updates can leave one full copy per
433
+ * release. The marker and materialized entries are removed together so the
434
+ * cache stays internally consistent.
435
+ */
436
+ export async function pruneBunInstallCache(
437
+ cacheDir: string,
438
+ packageNames?: Set<string>,
439
+ ): Promise<BunInstallCachePruneResult> {
440
+ const groups = await collectBunCacheGroups(cacheDir, packageNames);
441
+ let scannedPackages = 0;
442
+ let removedEntries = 0;
443
+ for (const group of groups.values()) {
444
+ if (group.actualDirs.size === 0) continue;
445
+ scannedPackages++;
446
+ let latestVersion: string | undefined;
447
+ for (const version of group.actualDirs.keys()) {
448
+ if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version;
449
+ }
450
+ if (!latestVersion) continue;
451
+ for (const [version, paths] of group.actualDirs) {
452
+ if (version !== latestVersion) removedEntries += await removeCacheEntries(paths);
453
+ }
454
+ for (const [version, paths] of group.markerEntries) {
455
+ if (version !== latestVersion) removedEntries += await removeCacheEntries(paths);
456
+ }
457
+ }
458
+ return { scannedPackages, removedEntries };
459
+ }
460
+
461
+ async function resolveBunInstallCacheDir(): Promise<string | undefined> {
462
+ try {
463
+ const result = await $`bun pm cache`.quiet().nothrow();
464
+ if (result.exitCode !== 0) return undefined;
465
+ const output = result.text().trim();
466
+ return output.length > 0 ? output : undefined;
467
+ } catch {
468
+ return undefined;
469
+ }
470
+ }
471
+
472
+ export function resolveBunGlobalNodeModulesDirFromLocations(
473
+ globalBinDir: string | undefined,
474
+ cacheDir: string | undefined,
475
+ ): string | undefined {
476
+ if (globalBinDir && globalBinDir.length > 0) {
477
+ return path.join(path.dirname(globalBinDir), "install", "global", "node_modules");
478
+ }
479
+ if (cacheDir && cacheDir.length > 0) {
480
+ return path.join(path.dirname(cacheDir), "global", "node_modules");
481
+ }
482
+ return undefined;
483
+ }
484
+
485
+ async function resolveBunGlobalNodeModulesDir(cacheDir: string): Promise<string | undefined> {
486
+ try {
487
+ const result = await $`bun pm bin -g`.quiet().nothrow();
488
+ const globalBinDir = result.exitCode === 0 ? result.text().trim() : undefined;
489
+ return resolveBunGlobalNodeModulesDirFromLocations(globalBinDir, cacheDir);
490
+ } catch {
491
+ return resolveBunGlobalNodeModulesDirFromLocations(undefined, cacheDir);
492
+ }
493
+ }
494
+
495
+ async function collectInstalledPackageNames(nodeModulesDir: string): Promise<Set<string>> {
496
+ const packageNames = new Set<string>();
497
+ for (const entry of await readdirIfExists(nodeModulesDir)) {
498
+ if (!entry.isDirectory() || entry.name === ".bin") continue;
499
+ if (entry.name.startsWith("@")) {
500
+ for (const scopedEntry of await readdirIfExists(path.join(nodeModulesDir, entry.name))) {
501
+ if (scopedEntry.isDirectory()) packageNames.add(`${entry.name}/${scopedEntry.name}`);
502
+ }
503
+ continue;
504
+ }
505
+ packageNames.add(entry.name);
506
+ }
507
+ return packageNames;
508
+ }
509
+
510
+ async function pruneBunCacheAfterGlobalInstall(): Promise<BunInstallCachePruneResult | undefined> {
511
+ const cacheDir = await resolveBunInstallCacheDir();
512
+ if (!cacheDir) return undefined;
513
+ const globalNodeModulesDir = await resolveBunGlobalNodeModulesDir(cacheDir);
514
+ const packageNames = globalNodeModulesDir
515
+ ? await collectInstalledPackageNames(globalNodeModulesDir)
516
+ : new Set<string>();
517
+ if (packageNames.size === 0 && !path.basename(cacheDir).toLowerCase().includes("omp")) return undefined;
518
+ return await pruneBunInstallCache(cacheDir, packageNames.size === 0 ? undefined : packageNames);
519
+ }
520
+
275
521
  /**
276
522
  * Get the appropriate binary name for this platform.
277
523
  */
@@ -524,6 +770,14 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
524
770
  }
525
771
 
526
772
  await printVerification(expectedVersion);
773
+ try {
774
+ const pruneResult = await pruneBunCacheAfterGlobalInstall();
775
+ if (pruneResult && pruneResult.removedEntries > 0) {
776
+ console.log(chalk.dim(`Pruned ${pruneResult.removedEntries} stale Bun cache entries`));
777
+ }
778
+ } catch (err) {
779
+ console.log(chalk.yellow(`Warning: could not prune stale Bun cache entries: ${err}`));
780
+ }
527
781
  }
528
782
 
529
783
  async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise<void> {
@@ -7,9 +7,9 @@
7
7
  * containing a `.git` *file* that points back at
8
8
  * `<parent-repo>/.git/worktrees/<name>/`.
9
9
  * - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
10
- * `merged` subdir mounted/cloned by `natives.isoStart`. These are ephemeral
11
- * `ensureIsolation` always `rm -rf`s the base before re-creating it, so
12
- * any leftover on disk is a leak from a crashed run.
10
+ * compact `m` subdir mounted/cloned by `natives.isoStart`. Legacy `merged`
11
+ * subdirs are still recognized. These are ephemeral; `ensureIsolation`
12
+ * removes the base before re-creating it, so leftovers are crashed runs.
13
13
  *
14
14
  * Legacy entries from before the encoding change keep working because git still
15
15
  * tracks them by branch name. This command exists to GC them on demand.
@@ -22,6 +22,8 @@ import * as git from "../utils/git";
22
22
 
23
23
  type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
24
24
 
25
+ const TASK_ISOLATION_MOUNT_DIRS = ["m", "merged"] as const;
26
+
25
27
  export interface WorktreeEntry {
26
28
  /** Absolute path to the worktree dir (or stray container) under `~/.omp/wt/`. */
27
29
  path: string;
@@ -209,8 +211,9 @@ async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
209
211
  if (gitStat?.isFile()) {
210
212
  return classifyPrCheckout(dir, gitEntry);
211
213
  }
212
- const mergedStat = await fs.stat(path.join(dir, "merged")).catch(() => null);
213
- if (mergedStat?.isDirectory()) {
214
+ for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
215
+ const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
216
+ if (!mountStat?.isDirectory()) continue;
214
217
  return {
215
218
  path: dir,
216
219
  kind: "task-isolation",
@@ -37,6 +37,7 @@ import {
37
37
  parseCollabLink,
38
38
  } from "./protocol";
39
39
  import { CollabSocket } from "./relay-client";
40
+ import { shrinkForReplication } from "./replication-shrink";
40
41
 
41
42
  /** Events that change the footer state guests render. */
42
43
  const STATE_TRIGGER_EVENTS: Record<string, true> = {
@@ -212,7 +213,7 @@ export class CollabHost {
212
213
  }
213
214
 
214
215
  this.#unsubscribe = this.#ctx.session.subscribe(event => {
215
- if (isWireAgentEvent(event)) this.#broadcast({ t: "event", event });
216
+ if (isWireAgentEvent(event)) this.#broadcast({ t: "event", event: shrinkForReplication(event) });
216
217
  this.#onEventForState(event);
217
218
  });
218
219
  const bus = this.#ctx.eventBus;
@@ -223,7 +224,7 @@ export class CollabHost {
223
224
  }
224
225
  this.#registryUnsubscribe = AgentRegistry.global().onChange(() => this.#scheduleAgentsBroadcast());
225
226
  this.#ctx.sessionManager.onEntryAppended = entry => {
226
- if (isWireSessionEntry(entry)) this.#broadcast({ t: "entry", entry });
227
+ if (isWireSessionEntry(entry)) this.#broadcast({ t: "entry", entry: shrinkForReplication(entry) });
227
228
  // Model/thinking/title changes land as entries while idle; refresh
228
229
  // guest state promptly (debounce + JSON diff dedupe).
229
230
  this.#scheduleStateBroadcast();
@@ -358,10 +359,13 @@ export class CollabHost {
358
359
 
359
360
  /**
360
361
  * Slice {@link entries} into byte-bounded `snapshot-chunk` frames targeted
361
- * at {@link fromPeer}. Every batch carries at least one entry (a single
362
- * oversize entry ships alone), and the last batch is tagged `final: true`
363
- * so the guest can finalize the replica. An empty snapshot still emits one
364
- * `final` chunk so the guest never blocks on a missing terminator.
362
+ * at {@link fromPeer}. Each entry is first run through
363
+ * {@link shrinkForReplication} so a single oversized tool-result entry
364
+ * cannot ship as an oversized chunk that trips the relay's per-frame
365
+ * `maxPayloadLength` (issue #3739). Every batch carries at least one
366
+ * entry, and the last batch is tagged `final: true` so the guest can
367
+ * finalize the replica. An empty snapshot still emits one `final` chunk
368
+ * so the guest never blocks on a missing terminator.
365
369
  */
366
370
  #sendSnapshotChunks(entries: (StoredSessionEntry & WireSessionEntry)[], fromPeer: number): void {
367
371
  const socket = this.#socket;
@@ -377,9 +381,10 @@ export class CollabHost {
377
381
  while (i < entries.length) {
378
382
  const entry = entries[i];
379
383
  if (!entry) break;
380
- const entryBytes = JSON.stringify(entry).length;
384
+ const shrunk = shrinkForReplication(entry);
385
+ const entryBytes = JSON.stringify(shrunk).length;
381
386
  if (batch.length > 0 && batchBytes + entryBytes > SNAPSHOT_CHUNK_BYTES) break;
382
- batch.push(entry);
387
+ batch.push(shrunk);
383
388
  batchBytes += entryBytes;
384
389
  i++;
385
390
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Hard-cap helper for host→guest collab frames.
3
+ *
4
+ * The host wraps every {@link CollabFrame} in an AES-GCM envelope and ships it
5
+ * through the relay's WebSocket. WebSocket servers enforce a per-frame
6
+ * `maxPayloadLength` (Bun's default is 16 MB; many proxies cap lower). A
7
+ * single oversized payload — typically a `read`/`bash`/`search` tool result
8
+ * captured as one multi-megabyte string, or a tool result whose `content`
9
+ * array holds thousands of small blocks — would otherwise ship as its own
10
+ * oversized frame and trip that limit, killing the host's WebSocket with
11
+ * `1006 Received too big message`. `CollabSocket` treats 1006 as transient
12
+ * and reconnects, the next guest hello triggers the same oversized send, and
13
+ * the loop never breaks (issue #3739).
14
+ *
15
+ * This helper bounds any JSON-serializable payload below
16
+ * {@link MAX_REPLICATED_PAYLOAD_BYTES}. Already-small payloads pass through
17
+ * untouched; oversized ones are returned as a deep-cloned shadow where long
18
+ * strings are head-truncated AND long arrays are head-clipped, with
19
+ * `[…N chars elided for collab session]` / `[…N items elided for collab
20
+ * session]` markers. Both axes are needed: string truncation alone leaves
21
+ * the cap unenforced for a payload built of many short strings, where no
22
+ * field exceeds the per-string floor.
23
+ */
24
+
25
+ /**
26
+ * Per-payload ceiling for host→guest frames. Bun's default WebSocket
27
+ * `maxPayloadLength` is 16 MB; we leave a generous margin so the AES-GCM
28
+ * envelope (+ IV + tag), the 4-byte peer header, and the outer wire wrapper
29
+ * fit comfortably under that on every reasonable relay.
30
+ */
31
+ export const MAX_REPLICATED_PAYLOAD_BYTES = 1 * 1024 * 1024;
32
+
33
+ /**
34
+ * Progressive shrink passes. Each pass tightens both the per-string cap and
35
+ * the per-array head limit; the loop stops at the first pass whose output
36
+ * fits {@link MAX_REPLICATED_PAYLOAD_BYTES}. The schedule is concrete (not
37
+ * recomputed) so the failure modes the helper guards against are visible:
38
+ *
39
+ * - One giant string → the first pass already truncates it under 64 KB.
40
+ * - Array of many small blocks (e.g. a tool result with thousands of
41
+ * `{type:"text", text:"..."}` content items) → later passes head-clip the
42
+ * array to a small sample with a `[…N items elided]` summary element.
43
+ *
44
+ * The final pass clamps every string to 64 B and every array to one element,
45
+ * so even pathological mixes converge.
46
+ */
47
+ interface ShrinkPass {
48
+ stringCap: number;
49
+ arrayLimit: number;
50
+ }
51
+
52
+ const SHRINK_PASSES: readonly ShrinkPass[] = [
53
+ { stringCap: 64 * 1024, arrayLimit: 256 },
54
+ { stringCap: 16 * 1024, arrayLimit: 128 },
55
+ { stringCap: 4 * 1024, arrayLimit: 64 },
56
+ { stringCap: 1 * 1024, arrayLimit: 32 },
57
+ { stringCap: 256, arrayLimit: 16 },
58
+ { stringCap: 256, arrayLimit: 4 },
59
+ { stringCap: 64, arrayLimit: 1 },
60
+ ];
61
+
62
+ const STRING_ELISION_RESERVE = 80;
63
+
64
+ /**
65
+ * Recursively walk `value`, head-truncating any string longer than
66
+ * `stringCap` and head-clipping any array longer than `arrayLimit`. Returns
67
+ * a freshly built deep clone — every object/array is rebuilt so the
68
+ * recursive output can be safely serialized in isolation. Cheap to call on
69
+ * small values: short strings, numbers, and booleans pass through without
70
+ * allocation.
71
+ */
72
+ function shrinkWalk(value: unknown, stringCap: number, arrayLimit: number): unknown {
73
+ if (typeof value === "string") {
74
+ if (value.length <= stringCap) return value;
75
+ const headLen = Math.max(0, stringCap - STRING_ELISION_RESERVE);
76
+ return `${value.slice(0, headLen)}\n…[${value.length - headLen} chars elided for collab session]`;
77
+ }
78
+ if (Array.isArray(value)) {
79
+ const keep = Math.min(value.length, arrayLimit);
80
+ const elided = value.length - keep;
81
+ const out: unknown[] = new Array(elided > 0 ? keep + 1 : keep);
82
+ for (let i = 0; i < keep; i++) out[i] = shrinkWalk(value[i], stringCap, arrayLimit);
83
+ if (elided > 0) out[keep] = `…[${elided} items elided for collab session]`;
84
+ return out;
85
+ }
86
+ if (value && typeof value === "object") {
87
+ const src = value as Record<string, unknown>;
88
+ const out: Record<string, unknown> = {};
89
+ for (const k in src) out[k] = shrinkWalk(src[k], stringCap, arrayLimit);
90
+ return out;
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /**
96
+ * Return `value` unchanged when its JSON serialization already fits
97
+ * {@link MAX_REPLICATED_PAYLOAD_BYTES}; otherwise return a deep-cloned
98
+ * shadow shrunk along both string and array axes until the payload fits.
99
+ * The function is generic over `T` because the wire shape is preserved:
100
+ * only string leaves and array tails change; discriminator fields, ids, and
101
+ * other small metadata pass through untouched.
102
+ */
103
+ export function shrinkForReplication<T>(value: T): T {
104
+ if (JSON.stringify(value).length <= MAX_REPLICATED_PAYLOAD_BYTES) return value;
105
+ let shrunk: unknown = value;
106
+ for (const pass of SHRINK_PASSES) {
107
+ shrunk = shrinkWalk(value, pass.stringCap, pass.arrayLimit);
108
+ if (JSON.stringify(shrunk).length <= MAX_REPLICATED_PAYLOAD_BYTES) return shrunk as T;
109
+ }
110
+ return shrunk as T;
111
+ }
@@ -126,7 +126,8 @@ type LlamaCppDiscoveredServerMetadata = {
126
126
 
127
127
  type LlamaCppModelListEntry = {
128
128
  id: string;
129
- contextWindow?: number;
129
+ runtimeContextWindow?: number;
130
+ trainingContextWindow?: number;
130
131
  };
131
132
 
132
133
  function toPositiveNumberOrUndefined(value: unknown): number | undefined {
@@ -142,7 +143,21 @@ function toPositiveNumberOrUndefined(value: unknown): number | undefined {
142
143
  return undefined;
143
144
  }
144
145
 
146
+ function extractOllamaRuntimeContextWindow(payload: Record<string, unknown>): number | undefined {
147
+ const parameters = payload.parameters;
148
+ if (typeof parameters !== "string") {
149
+ return undefined;
150
+ }
151
+ const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
152
+ return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
153
+ }
154
+
145
155
  function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
156
+ const runtimeContextWindow = extractOllamaRuntimeContextWindow(payload);
157
+ if (runtimeContextWindow !== undefined) {
158
+ return runtimeContextWindow;
159
+ }
160
+
146
161
  const modelInfo = payload.model_info;
147
162
  if (isRecord(modelInfo)) {
148
163
  for (const [key, value] of Object.entries(modelInfo)) {
@@ -155,12 +170,7 @@ function extractOllamaContextWindow(payload: Record<string, unknown>): number |
155
170
  }
156
171
  }
157
172
 
158
- const parameters = payload.parameters;
159
- if (typeof parameters !== "string") {
160
- return undefined;
161
- }
162
- const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
163
- return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
173
+ return undefined;
164
174
  }
165
175
 
166
176
  function extractLlamaCppContextWindow(payload: Record<string, unknown>): number | undefined {
@@ -174,12 +184,17 @@ function extractLlamaCppContextWindow(payload: Record<string, unknown>): number
174
184
  return toPositiveNumberOrUndefined(payload.n_ctx);
175
185
  }
176
186
 
177
- function extractLlamaCppModelContextWindow(item: Record<string, unknown>): number | undefined {
187
+ function extractLlamaCppModelContextWindows(
188
+ item: Record<string, unknown>,
189
+ ): Pick<LlamaCppModelListEntry, "runtimeContextWindow" | "trainingContextWindow"> {
178
190
  const meta = item.meta;
179
191
  if (!isRecord(meta)) {
180
- return undefined;
192
+ return {};
181
193
  }
182
- return toPositiveNumberOrUndefined(meta.n_ctx) ?? toPositiveNumberOrUndefined(meta.n_ctx_train);
194
+ return {
195
+ runtimeContextWindow: toPositiveNumberOrUndefined(meta.n_ctx),
196
+ trainingContextWindow: toPositiveNumberOrUndefined(meta.n_ctx_train),
197
+ };
183
198
  }
184
199
 
185
200
  function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
@@ -190,7 +205,7 @@ function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
190
205
  if (!isRecord(item) || typeof item.id !== "string" || !item.id) {
191
206
  return [];
192
207
  }
193
- return [{ id: item.id, contextWindow: extractLlamaCppModelContextWindow(item) }];
208
+ return [{ id: item.id, ...extractLlamaCppModelContextWindows(item) }];
194
209
  });
195
210
  }
196
211
 
@@ -378,7 +393,11 @@ export async function discoverLlamaCppModels(
378
393
  for (const item of models) {
379
394
  const { id } = item;
380
395
  if (!id) continue;
381
- const contextWindow = item.contextWindow ?? serverMetadata?.contextWindow ?? DISCOVERY_DEFAULT_CONTEXT_WINDOW;
396
+ const contextWindow =
397
+ item.runtimeContextWindow ??
398
+ serverMetadata?.contextWindow ??
399
+ item.trainingContextWindow ??
400
+ DISCOVERY_DEFAULT_CONTEXT_WINDOW;
382
401
  discovered.push(
383
402
  buildModel({
384
403
  id,
@@ -420,7 +439,8 @@ export async function discoverLlamaCppModelContextWindow(
420
439
  return undefined;
421
440
  }
422
441
  const entries = parseLlamaCppModelList(await response.json());
423
- return entries.find(entry => entry.id === model.id)?.contextWindow;
442
+ const entry = entries.find(entry => entry.id === model.id);
443
+ return entry?.runtimeContextWindow ?? entry?.trainingContextWindow;
424
444
  };
425
445
  try {
426
446
  const apiKey = await ctx.getBearerApiKeyResolver(model.provider);
@@ -0,0 +1,25 @@
1
+ import * as imageGen from "../tools/image-gen";
2
+ import * as webSearch from "../web/search";
3
+
4
+ interface ProviderGlobalSettings {
5
+ get(path: "providers.webSearchExclude"): unknown;
6
+ get(path: "providers.webSearch"): unknown;
7
+ get(path: "providers.image"): unknown;
8
+ }
9
+
10
+ export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void {
11
+ const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
12
+ if (Array.isArray(excludedWebSearchProviders)) {
13
+ webSearch.setExcludedSearchProviders(excludedWebSearchProviders.filter(webSearch.isSearchProviderId));
14
+ }
15
+
16
+ const webSearchProvider = settings.get("providers.webSearch");
17
+ if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) {
18
+ webSearch.setPreferredSearchProvider(webSearchProvider);
19
+ }
20
+
21
+ const imageProvider = settings.get("providers.image");
22
+ if (imageGen.isImageProviderPreference(imageProvider)) {
23
+ imageGen.setPreferredImageProvider(imageProvider);
24
+ }
25
+ }
@@ -317,8 +317,11 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
317
317
  // `>` must sit outside quoted regions (so `echo "a -> b"` passes) and be
318
318
  // followed by a plausible filename — including `$VAR` targets; `>|`
319
319
  // (clobber) counts as a redirect; `>&2`/`2>&1` style fd duplication is
320
- // not matched.
321
- pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+(?:[^\"'>]|\"[^\"]*\"|'[^']*')*(?<!\\|)>{1,2}\\|?\\s*[$\\w./~\"'-]",
320
+ // not matched. Allowed device sinks are consumed while looking for later
321
+ // real file redirects because the write tool cannot replace shell
322
+ // output/discard targets.
323
+ pattern:
324
+ "^\\s*(echo|printf|cat\\s*<<)\\s+(?:(?:[^\"'>]|\"[^\"]*\"|'[^']*')|(?<!\\|)>{1,2}\\|?\\s*(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))*(?<!\\|)>{1,2}\\|?\\s*(?!(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))[$\\w./~\"'-]",
322
325
  tool: "write",
323
326
  message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
324
327
  },
@@ -9,7 +9,7 @@ const OWNER_ID = "julia-prelude-tests";
9
9
  describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
10
10
  afterEach(async () => {
11
11
  await disposeJuliaKernelSessionsByOwner(OWNER_ID);
12
- });
12
+ }, 30_000);
13
13
 
14
14
  it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
15
15
  using tempDir = TempDir.createSync("@omp-eval-julia-output-");
@@ -44,7 +44,7 @@ nothing
44
44
  expect(result.output).toContain("STRIPPED=red");
45
45
  expect(result.output).toContain("META=alpha:true");
46
46
  expect(result.output).toContain("MULTI=2:alpha:json");
47
- }, 30_000);
47
+ }, 60_000);
48
48
 
49
49
  it("surfaces the exception type and message in the error output, not just stack frames", async () => {
50
50
  using tempDir = TempDir.createSync("@omp-eval-julia-error-");