@oh-my-pi/pi-coding-agent 16.2.8 → 16.2.11

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 (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli.js +3160 -3096
  3. package/dist/types/config/settings-schema.d.ts +41 -13
  4. package/dist/types/extensibility/skills.d.ts +29 -0
  5. package/dist/types/modes/components/mcp-add-wizard.d.ts +8 -0
  6. package/dist/types/modes/components/todo-reminder.d.ts +3 -1
  7. package/dist/types/modes/controllers/mcp-command-controller.d.ts +9 -0
  8. package/dist/types/modes/controllers/tool-args-reveal.d.ts +5 -0
  9. package/dist/types/modes/interactive-mode.d.ts +0 -1
  10. package/dist/types/modes/skill-command.d.ts +1 -1
  11. package/dist/types/modes/types.d.ts +0 -1
  12. package/dist/types/session/agent-session.d.ts +1 -1
  13. package/dist/types/stt/asr-client.d.ts +7 -3
  14. package/dist/types/stt/index.d.ts +1 -0
  15. package/dist/types/stt/stt-controller.d.ts +2 -0
  16. package/dist/types/stt/submit-trigger.d.ts +30 -0
  17. package/dist/types/task/index.d.ts +1 -1
  18. package/dist/types/task/types.d.ts +6 -6
  19. package/dist/types/tiny/models.d.ts +22 -8
  20. package/package.json +14 -13
  21. package/scripts/bundle-dist.ts +23 -4
  22. package/scripts/generate-docs-index.ts +116 -24
  23. package/src/async/job-manager.ts +27 -3
  24. package/src/cli/grep-cli.ts +1 -1
  25. package/src/commit/agentic/agent.ts +1 -1
  26. package/src/commit/agentic/prompts/system.md +1 -1
  27. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  28. package/src/config/model-discovery.ts +118 -76
  29. package/src/config/settings-schema.ts +15 -1
  30. package/src/debug/profiler.ts +7 -1
  31. package/src/extensibility/skills.ts +77 -0
  32. package/src/internal-urls/docs-index.generated.txt +2 -2
  33. package/src/lsp/config.ts +17 -3
  34. package/src/mcp/oauth-flow.ts +35 -8
  35. package/src/modes/acp/acp-agent.ts +6 -9
  36. package/src/modes/components/mcp-add-wizard.ts +43 -3
  37. package/src/modes/components/model-selector.ts +21 -9
  38. package/src/modes/components/todo-reminder.ts +5 -1
  39. package/src/modes/controllers/event-controller.ts +40 -15
  40. package/src/modes/controllers/mcp-command-controller.ts +84 -3
  41. package/src/modes/controllers/selector-controller.ts +57 -35
  42. package/src/modes/controllers/tool-args-reveal.ts +12 -0
  43. package/src/modes/interactive-mode.ts +5 -10
  44. package/src/modes/rpc/rpc-mode.ts +5 -8
  45. package/src/modes/skill-command.ts +8 -20
  46. package/src/modes/types.ts +0 -1
  47. package/src/prompts/agents/tester.md +107 -0
  48. package/src/prompts/system/orchestrate-notice.md +2 -2
  49. package/src/prompts/system/system-prompt.md +2 -5
  50. package/src/prompts/system/thinking-loop-redirect.md +10 -0
  51. package/src/prompts/system/workflow-notice.md +1 -1
  52. package/src/prompts/tools/task.md +2 -9
  53. package/src/session/agent-session.ts +53 -18
  54. package/src/stt/asr-client.ts +87 -27
  55. package/src/stt/downloader.ts +8 -2
  56. package/src/stt/index.ts +1 -0
  57. package/src/stt/stt-controller.ts +31 -2
  58. package/src/stt/submit-trigger.ts +74 -0
  59. package/src/task/agents.ts +4 -4
  60. package/src/task/executor.ts +2 -4
  61. package/src/task/index.ts +32 -10
  62. package/src/task/types.ts +5 -5
  63. package/src/tiny/models.ts +10 -0
  64. package/src/tools/ast-grep.ts +34 -12
  65. package/src/tools/grep.ts +11 -8
  66. package/src/utils/git.ts +22 -1
  67. package/src/prompts/agents/oracle.md +0 -54
@@ -1,56 +1,148 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Populate (or reset) the embedded harness documentation index for `omp://`.
4
+ * Populate, check, or reset the embedded harness documentation index for `omp://`.
5
5
  *
6
6
  * `--generate` writes `src/internal-urls/docs-index.generated.txt` as two lines:
7
7
  * a plain JSON array of the sorted `docs/**\/*.md` file names, then a base64
8
- * gzip blob of the index-aligned doc bodies (`string[]`). Keeping the filename
9
- * list out of the blob lets the loader list docs without inflating it.
10
- * Compiled binaries and the prepacked npm bundle inline this (~0.5MB) instead of
11
- * the ~1.6MB raw map; `--reset` restores the checked-in empty placeholder so the
8
+ * gzip blob of the index-aligned doc bodies (`string[]`). `--check` rebuilds
9
+ * that payload from the real docs corpus and compares it to the embed when
10
+ * present; the checked-in empty placeholder is accepted after verifying that a
11
+ * fresh generated payload round-trips. `--reset` restores the placeholder so the
12
12
  * dev tree reads `docs/` from disk. Mirrors the stats / model-catalog embeds.
13
13
  */
14
14
 
15
15
  import * as path from "node:path";
16
- import { gzipSync } from "node:zlib";
16
+ import { gunzipSync, gzipSync } from "node:zlib";
17
17
  import { Glob } from "bun";
18
18
 
19
19
  const docsDir = path.resolve(import.meta.dir, "../../../docs");
20
20
  const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.txt");
21
21
  const GENERATE_FLAG = "--generate";
22
22
  const RESET_FLAG = "--reset";
23
+ const CHECK_FLAG = "--check";
24
+
25
+ export interface DocsIndexPayload {
26
+ /** Sorted `docs/**\/*.md` file names plus index-aligned bodies and embed text. */
27
+ readonly files: readonly string[];
28
+ readonly bodies: readonly string[];
29
+ readonly payload: string;
30
+ }
31
+
32
+ export interface DecodedDocsIndexPayload {
33
+ /** Sorted `docs/**\/*.md` file names decoded from an embed payload. */
34
+ readonly files: readonly string[];
35
+ /** Index-aligned Markdown bodies decoded from an embed payload. */
36
+ readonly bodies: readonly string[];
37
+ }
38
+
39
+ function isStringArray(value: unknown): value is string[] {
40
+ return Array.isArray(value) && value.every(item => typeof item === "string");
41
+ }
42
+
43
+ /** Build the exact two-line `omp://` docs embed from the source `docs/**\/*.md` corpus. */
44
+ export async function buildDocsIndexPayload(): Promise<DocsIndexPayload> {
45
+ const glob = new Glob("**/*.md");
46
+ const files: string[] = [];
47
+ for await (const relativePath of glob.scan(docsDir)) {
48
+ files.push(relativePath.split(path.sep).join("/"));
49
+ }
50
+ files.sort();
51
+
52
+ const bodies = await Promise.all(files.map(file => Bun.file(path.join(docsDir, file)).text()));
53
+ const bodiesB64 = Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)), { level: 9 })).toString("base64");
54
+ return {
55
+ files,
56
+ bodies,
57
+ payload: `${JSON.stringify(files)}\n${bodiesB64}`,
58
+ };
59
+ }
60
+
61
+ /** Decode a populated docs embed payload into filenames and index-aligned Markdown bodies. */
62
+ export function decodeDocsIndexPayload(embed: string): DecodedDocsIndexPayload | null {
63
+ const newline = embed.indexOf("\n");
64
+ if (newline === -1) return null;
65
+
66
+ const filenames: unknown = JSON.parse(embed.slice(0, newline));
67
+ if (!isStringArray(filenames)) {
68
+ throw new Error("Embedded docs index filename line is not a JSON string array.");
69
+ }
70
+
71
+ const inflated = gunzipSync(Buffer.from(embed.slice(newline + 1), "base64"));
72
+ const bodies: unknown = JSON.parse(inflated.toString("utf8"));
73
+ if (!isStringArray(bodies)) {
74
+ throw new Error("Embedded docs index body blob is not a JSON string array.");
75
+ }
76
+
77
+ return { files: filenames, bodies };
78
+ }
79
+
80
+ /**
81
+ * Assert that an embed payload is fresh against the current source docs payload.
82
+ * An empty placeholder is accepted by round-tripping the expected payload (the
83
+ * dev tree and post-build reset state both checked-in placeholders).
84
+ */
85
+ export function assertDocsIndexFresh(embed: string, expected: DecodedDocsIndexPayload): void {
86
+ const source =
87
+ embed.length > 0
88
+ ? embed
89
+ : `${JSON.stringify(expected.files)}\n${Buffer.from(gzipSync(Buffer.from(JSON.stringify(expected.bodies)), { level: 9 })).toString("base64")}`;
90
+ const decoded = decodeDocsIndexPayload(source);
91
+ if (decoded === null) {
92
+ throw new Error("Embedded docs index is malformed: missing newline separator.");
93
+ }
94
+ if (decoded.files.length !== expected.files.length) {
95
+ throw new Error(
96
+ `Embedded docs index has ${decoded.files.length} docs; source corpus has ${expected.files.length}.`,
97
+ );
98
+ }
99
+ if (decoded.bodies.length !== expected.bodies.length) {
100
+ throw new Error(
101
+ `Embedded docs index has ${decoded.bodies.length} bodies; source corpus has ${expected.bodies.length}.`,
102
+ );
103
+ }
104
+ for (let i = 0; i < expected.files.length; i++) {
105
+ if (decoded.files[i] !== expected.files[i]) {
106
+ throw new Error(
107
+ `Embedded docs index filename mismatch at ${i}: ${decoded.files[i] ?? "<missing>"} !== ${expected.files[i]}.`,
108
+ );
109
+ }
110
+ if (decoded.bodies[i] !== expected.bodies[i]) {
111
+ throw new Error(`Embedded docs index body mismatch for ${expected.files[i]}. Run \`bun run gen:docs\`.`);
112
+ }
113
+ }
114
+ }
23
115
 
24
116
  async function main(): Promise<void> {
25
117
  const rel = path.relative(process.cwd(), outputPath);
26
118
 
27
119
  if (process.argv.includes(RESET_FLAG)) {
28
120
  await Bun.write(outputPath, "");
29
- console.log(`Reset ${rel}`);
121
+ process.stdout.write(`Reset ${rel}\n`);
30
122
  return;
31
123
  }
32
124
 
33
- if (!process.argv.includes(GENERATE_FLAG)) {
34
- console.log(`Skipping ${rel}; pass ${GENERATE_FLAG} to embed docs (the dev tree reads docs/ from disk)`);
125
+ if (process.argv.includes(CHECK_FLAG)) {
126
+ const current = await buildDocsIndexPayload();
127
+ const embed = await Bun.file(outputPath).text();
128
+ assertDocsIndexFresh(embed, current);
129
+ process.stdout.write(`Docs index fresh for ${current.files.length} docs (${rel})\n`);
35
130
  return;
36
131
  }
37
132
 
38
- const glob = new Glob("**/*.md");
39
- const files: string[] = [];
40
- for await (const relativePath of glob.scan(docsDir)) {
41
- files.push(relativePath.split(path.sep).join("/"));
133
+ if (!process.argv.includes(GENERATE_FLAG)) {
134
+ process.stdout.write(
135
+ `Skipping ${rel}; pass ${GENERATE_FLAG} to embed docs (the dev tree reads docs/ from disk)\n`,
136
+ );
137
+ return;
42
138
  }
43
- files.sort();
44
-
45
- // Index-aligned bodies (Promise.all preserves order), kept separate from the
46
- // filename list so the loader can list docs without inflating the blob.
47
- const bodies = await Promise.all(files.map(file => Bun.file(path.join(docsDir, file)).text()));
48
139
 
49
- const bodiesB64 = Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)), { level: 9 })).toString("base64");
50
- // Two lines: plain filename array, then the base64 gzip blob.
51
- const payload = `${JSON.stringify(files)}\n${bodiesB64}`;
52
- await Bun.write(outputPath, payload);
53
- console.log(`Generated ${rel} (${files.length} docs, ${payload.length} bytes)`);
140
+ const current = await buildDocsIndexPayload();
141
+ assertDocsIndexFresh(current.payload, current);
142
+ await Bun.write(outputPath, current.payload);
143
+ process.stdout.write(`Generated ${rel} (${current.files.length} docs, ${current.payload.length} bytes)\n`);
54
144
  }
55
145
 
56
- await main();
146
+ if (import.meta.main) {
147
+ await main();
148
+ }
@@ -397,6 +397,27 @@ export class AsyncJobManager {
397
397
  await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
398
398
  }
399
399
 
400
+ async #waitForAllUntil(deadline: number): Promise<boolean> {
401
+ const promises = Array.from(this.#jobs.values()).map(job => job.promise);
402
+ if (promises.length === 0) return true;
403
+ if (deadline === Number.POSITIVE_INFINITY) {
404
+ await Promise.all(promises);
405
+ return true;
406
+ }
407
+ const remainingMs = deadline - Date.now();
408
+ if (remainingMs <= 0) return false;
409
+
410
+ const timeout = Promise.withResolvers<"timeout">();
411
+ const timer = setTimeout(() => timeout.resolve("timeout"), remainingMs);
412
+ timer.unref();
413
+ try {
414
+ const result = await Promise.race([Promise.all(promises).then(() => "settled" as const), timeout.promise]);
415
+ return result === "settled";
416
+ } finally {
417
+ clearTimeout(timer);
418
+ }
419
+ }
420
+
400
421
  async drainDeliveries(options?: { timeoutMs?: number; filter?: AsyncJobFilter }): Promise<boolean> {
401
422
  const timeoutMs = options?.timeoutMs;
402
423
  const filter = options?.filter;
@@ -445,8 +466,10 @@ export class AsyncJobManager {
445
466
  this.#disposed = true;
446
467
  this.#clearEvictionTimers();
447
468
  this.cancelAll();
448
- await this.waitForAll();
449
- const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
469
+ const timeoutMs = Math.max(options?.timeoutMs ?? 3_000, 0);
470
+ const deadline = Date.now() + timeoutMs;
471
+ const jobsSettled = await this.#waitForAllUntil(deadline);
472
+ const drained = await this.drainDeliveries({ timeoutMs: Math.max(deadline - Date.now(), 0) });
450
473
  this.#clearEvictionTimers();
451
474
  this.#jobs.clear();
452
475
  this.#deliveries.length = 0;
@@ -454,7 +477,7 @@ export class AsyncJobManager {
454
477
  this.#suppressedDeliveries.clear();
455
478
  this.#watchedJobs.clear();
456
479
  this.#pollEscalation.clear();
457
- return drained;
480
+ return jobsSettled && drained;
458
481
  }
459
482
 
460
483
  #resolveJobId(preferredId?: string): string {
@@ -483,6 +506,7 @@ export class AsyncJobManager {
483
506
  }
484
507
 
485
508
  #scheduleEviction(jobId: string): void {
509
+ if (this.#disposed) return;
486
510
  if (this.#retentionMs <= 0) {
487
511
  this.#jobs.delete(jobId);
488
512
  this.#suppressedDeliveries.delete(jobId);
@@ -150,7 +150,7 @@ ${chalk.bold("Options:")}
150
150
  --no-gitignore Include files excluded by .gitignore
151
151
 
152
152
  ${chalk.bold("Environment:")}
153
- PI_GREP_WORKERS=N Set filesystem walker workers (default 4, 0 = auto)
153
+ PI_WALK_WORKERS=N Set filesystem walker workers (default 4, 0 = auto)
154
154
 
155
155
  ${chalk.bold("Examples:")}
156
156
  ${APP_NAME} grep "import" src/
@@ -42,7 +42,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
42
42
  types_description: typesDescription,
43
43
  });
44
44
  const state: CommitAgentState = { diffText: input.diffText };
45
- const spawns = "quick_task";
45
+ const spawns = "sonic";
46
46
  const tools = createCommitTools({
47
47
  cwd: input.cwd,
48
48
  authStorage: input.authStorage,
@@ -27,7 +27,7 @@ Tool guidance:
27
27
  - git_file_diff: diff for specific files
28
28
  - git_hunk: specific hunks for large diffs
29
29
  - recent_commits: recent commit subjects + style stats
30
- - analyze_files: spawn quick_task subagents in parallel for analysis
30
+ - analyze_files: spawn sonic subagents in parallel for analysis
31
31
  - propose_changelog: provide changelog entries for each changelog target
32
32
  - propose_commit: submit final commit proposal and run validation
33
33
  - split_commit: propose multiple commit groups (no overlapping files; all staged files covered)
@@ -63,7 +63,7 @@ export function createAnalyzeFileTool(options: {
63
63
  return {
64
64
  name: "analyze_files",
65
65
  label: "Analyze Files",
66
- description: "Spawn quick_task agents to analyze files.",
66
+ description: "Spawn sonic agents to analyze files.",
67
67
  parameters: analyzeFileSchema,
68
68
  async execute(toolCallId, params, _onUpdate, ctx, signal) {
69
69
  const toolSession = buildToolSession(ctx, options);
@@ -83,7 +83,7 @@ export function createAnalyzeFileTool(options: {
83
83
  related_files: relatedFiles,
84
84
  });
85
85
  const taskParams: TaskParams = {
86
- agent: "quick_task",
86
+ agent: "sonic",
87
87
  id: `AnalyzeFile${index + 1}`,
88
88
  description: `Analyze ${file}`,
89
89
  assignment,
@@ -35,6 +35,31 @@ import type { ProviderDiscovery } from "./models-config-schema";
35
35
  export const DISCOVERY_DEFAULT_CONTEXT_WINDOW = OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW;
36
36
  export const DISCOVERY_DEFAULT_MAX_TOKENS = OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS;
37
37
 
38
+ /**
39
+ * Run `fn` with an abort signal that fires after `timeoutMs`, clearing the
40
+ * backing timer the instant the operation settles.
41
+ *
42
+ * Unlike the built-in abort-signal timeout API, the timer never outlives the
43
+ * request: on the success path it is cancelled before `fn` resolves, so the
44
+ * signal is never aborted and no pending callback lingers on the heap. A leaked
45
+ * abort-signal timeout (e.g. discovery against a mocked fetch that resolves
46
+ * instantly) fires seconds later and sets its abort `reason` — which crashed
47
+ * Bun's concurrent GC while it marked the signal's wrapped reason during an
48
+ * unrelated allocation (`JSAbortSignal::visitAdditionalChildren`).
49
+ */
50
+ async function withTimeoutSignal<T>(timeoutMs: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(
53
+ () => controller.abort(new DOMException("The operation timed out.", "TimeoutError")),
54
+ timeoutMs,
55
+ );
56
+ try {
57
+ return await fn(controller.signal);
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+
38
63
  const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
39
64
  const OLLAMA_HOST_DEFAULT_PORT = "11434";
40
65
 
@@ -288,16 +313,18 @@ async function discoverOllamaModelMetadata(
288
313
  ): Promise<OllamaDiscoveredModelMetadata | null> {
289
314
  const showUrl = `${endpoint}/api/show`;
290
315
  try {
291
- const response = await ctx.fetch(showUrl, {
292
- method: "POST",
293
- headers: { ...(headers ?? {}), "Content-Type": "application/json" },
294
- body: JSON.stringify({ model: modelId }),
295
- signal: AbortSignal.timeout(150),
316
+ const payload = await withTimeoutSignal(150, async signal => {
317
+ const response = await ctx.fetch(showUrl, {
318
+ method: "POST",
319
+ headers: { ...(headers ?? {}), "Content-Type": "application/json" },
320
+ body: JSON.stringify({ model: modelId }),
321
+ signal,
322
+ });
323
+ if (!response.ok) {
324
+ return null;
325
+ }
326
+ return (await response.json()) as unknown;
296
327
  });
297
- if (!response.ok) {
298
- return null;
299
- }
300
- const payload = (await response.json()) as unknown;
301
328
  if (!isRecord(payload)) {
302
329
  return null;
303
330
  }
@@ -339,14 +366,16 @@ export async function discoverOllamaModels(
339
366
  const endpoint = normalizeOllamaBaseUrl(providerConfig.baseUrl);
340
367
  const tagsUrl = `${endpoint}/api/tags`;
341
368
  const headers = { ...(providerConfig.headers ?? {}) };
342
- const response = await ctx.fetch(tagsUrl, {
343
- headers,
344
- signal: AbortSignal.timeout(250),
369
+ const payload = await withTimeoutSignal(250, async signal => {
370
+ const response = await ctx.fetch(tagsUrl, {
371
+ headers,
372
+ signal,
373
+ });
374
+ if (!response.ok) {
375
+ throw new Error(`HTTP ${response.status} from ${tagsUrl}`);
376
+ }
377
+ return (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
345
378
  });
346
- if (!response.ok) {
347
- throw new Error(`HTTP ${response.status} from ${tagsUrl}`);
348
- }
349
- const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
350
379
  const entries = (payload.models ?? []).flatMap(item => {
351
380
  const id = item.model || item.name;
352
381
  return id ? [{ id, name: item.name || id }] : [];
@@ -384,14 +413,16 @@ async function discoverLlamaCppServerMetadata(
384
413
  ): Promise<LlamaCppDiscoveredServerMetadata | null> {
385
414
  const propsUrl = `${toLlamaCppNativeBaseUrl(baseUrl)}/props`;
386
415
  try {
387
- const response = await ctx.fetch(propsUrl, {
388
- headers,
389
- signal: AbortSignal.timeout(150),
416
+ const payload = await withTimeoutSignal(150, async signal => {
417
+ const response = await ctx.fetch(propsUrl, {
418
+ headers,
419
+ signal,
420
+ });
421
+ if (!response.ok) {
422
+ return null;
423
+ }
424
+ return (await response.json()) as unknown;
390
425
  });
391
- if (!response.ok) {
392
- return null;
393
- }
394
- const payload = (await response.json()) as unknown;
395
426
  if (!isRecord(payload)) {
396
427
  return null;
397
428
  }
@@ -415,24 +446,26 @@ export async function discoverLlamaCppModels(
415
446
  const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
416
447
  let headers = baseHeaders;
417
448
  const attempt = async (h: Record<string, string>) => {
418
- const [response, metadata] = await Promise.all([
419
- ctx.fetch(modelsUrl, {
420
- headers: h,
421
- signal: AbortSignal.timeout(250),
449
+ const [payload, metadata] = await Promise.all([
450
+ withTimeoutSignal(250, async signal => {
451
+ const response = await ctx.fetch(modelsUrl, {
452
+ headers: h,
453
+ signal,
454
+ });
455
+ if (!response.ok) {
456
+ throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
457
+ }
458
+ headers = h;
459
+ return (await response.json()) as unknown;
422
460
  }),
423
461
  discoverLlamaCppServerMetadata(ctx, baseUrl, h),
424
462
  ]);
425
- if (!response.ok) {
426
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
427
- }
428
- headers = h;
429
- return [response, metadata] as const;
463
+ return [payload, metadata] as const;
430
464
  };
431
465
  const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
432
- const [response, serverMetadata] = apiKey
466
+ const [payload, serverMetadata] = apiKey
433
467
  ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
434
468
  : await attempt(baseHeaders);
435
- const payload = (await response.json()) as unknown;
436
469
  const models = parseLlamaCppModelList(payload);
437
470
  const discovered: Model<Api>[] = [];
438
471
  for (const item of models) {
@@ -476,17 +509,22 @@ export async function discoverLlamaCppModelRuntimeMetadata(
476
509
  const modelsUrl = `${baseUrl}/models`;
477
510
  const baseHeaders: Record<string, string> = { ...(model.headers ?? {}) };
478
511
  const attempt = async (headers: Record<string, string>) => {
479
- const [response, serverMetadata] = await Promise.all([
480
- ctx.fetch(modelsUrl, {
481
- headers,
482
- signal: AbortSignal.timeout(250),
512
+ const [entries, serverMetadata] = await Promise.all([
513
+ withTimeoutSignal(250, async signal => {
514
+ const response = await ctx.fetch(modelsUrl, {
515
+ headers,
516
+ signal,
517
+ });
518
+ if (!response.ok) {
519
+ return undefined;
520
+ }
521
+ return parseLlamaCppModelList(await response.json());
483
522
  }),
484
523
  discoverLlamaCppServerMetadata(ctx, baseUrl, headers),
485
524
  ]);
486
- if (!response.ok) {
525
+ if (!entries) {
487
526
  return undefined;
488
527
  }
489
- const entries = parseLlamaCppModelList(await response.json());
490
528
  const entry = entries.find(entry => entry.id === model.id);
491
529
  if (!entry) {
492
530
  return undefined;
@@ -524,26 +562,28 @@ export async function discoverOpenAIModelsList(
524
562
  providerConfig.discovery.type === "lm-studio"
525
563
  ? fetchLmStudioNativeModelMetadata(baseUrl, ctx.fetch, { headers: h })
526
564
  : Promise.resolve(null);
527
- const [res, nativeMetadata] = await Promise.all([
528
- ctx.fetch(modelsUrl, {
529
- headers: h,
530
- signal: AbortSignal.timeout(10_000),
565
+ const [payload, nativeMetadata] = await Promise.all([
566
+ withTimeoutSignal(10_000, async signal => {
567
+ const res = await ctx.fetch(modelsUrl, {
568
+ headers: h,
569
+ signal,
570
+ });
571
+ if (!res.ok) {
572
+ throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
573
+ }
574
+ headers = h;
575
+ return (await res.json()) as {
576
+ data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>;
577
+ };
531
578
  }),
532
579
  nativeMetadataPromise,
533
580
  ]);
534
- if (!res.ok) {
535
- throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
536
- }
537
- headers = h;
538
- return [res, nativeMetadata] as const;
581
+ return [payload, nativeMetadata] as const;
539
582
  };
540
583
  const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
541
- const [response, nativeMetadata] = apiKey
584
+ const [payload, nativeMetadata] = apiKey
542
585
  ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
543
586
  : await attempt(baseHeaders);
544
- const payload = (await response.json()) as {
545
- data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>;
546
- };
547
587
  const models = payload.data ?? [];
548
588
  const discovered: Model<Api>[] = [];
549
589
  for (const item of models) {
@@ -600,15 +640,17 @@ export async function discoverLiteLLMModels(
600
640
  }
601
641
  return response;
602
642
  };
603
- const models = await fetchLiteLLMRichModels({
604
- api: providerConfig.api,
605
- provider: providerConfig.provider,
606
- baseUrl,
607
- headers: h,
608
- fetch: authAwareFetch,
609
- referenceResolver: resolveReference,
610
- signal: AbortSignal.timeout(10_000),
611
- });
643
+ const models = await withTimeoutSignal(10_000, signal =>
644
+ fetchLiteLLMRichModels({
645
+ api: providerConfig.api,
646
+ provider: providerConfig.provider,
647
+ baseUrl,
648
+ headers: h,
649
+ fetch: authAwareFetch,
650
+ referenceResolver: resolveReference,
651
+ signal,
652
+ }),
653
+ );
612
654
  if (authError && models === null) {
613
655
  throw authError;
614
656
  }
@@ -657,24 +699,24 @@ export async function discoverProxyModels(
657
699
 
658
700
  const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
659
701
  let headers = baseHeaders;
660
- const attempt = async (h: Record<string, string>) => {
661
- const res = await ctx.fetch(modelsUrl, {
662
- headers: h,
663
- signal: AbortSignal.timeout(10_000),
702
+ const attempt = async (h: Record<string, string>) =>
703
+ withTimeoutSignal(10_000, async signal => {
704
+ const res = await ctx.fetch(modelsUrl, {
705
+ headers: h,
706
+ signal,
707
+ });
708
+ if (!res.ok) {
709
+ throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
710
+ }
711
+ headers = h;
712
+ return (await res.json()) as {
713
+ data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[]; context_length?: number }>;
714
+ };
664
715
  });
665
- if (!res.ok) {
666
- throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
667
- }
668
- headers = h;
669
- return res;
670
- };
671
716
  const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
672
- const response = apiKey
717
+ const payload = apiKey
673
718
  ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
674
719
  : await attempt(baseHeaders);
675
- const payload = (await response.json()) as {
676
- data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[]; context_length?: number }>;
677
- };
678
720
  const items = payload.data ?? [];
679
721
  const discovered: Model<Api>[] = [];
680
722
  for (const item of items) {
@@ -3,6 +3,7 @@ import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
3
3
  import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact";
4
4
  import { DEFAULT_RELAY_URL } from "../collab/protocol";
5
5
  import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS, STT_MODEL_VALUES } from "../stt/models";
6
+ import { STT_SUBMIT_TRIGGER_OPTIONS, STT_SUBMIT_TRIGGER_VALUES } from "../stt/submit-trigger";
6
7
  import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking";
7
8
  import {
8
9
  TINY_MODEL_DEVICE_DEFAULT,
@@ -1794,6 +1795,19 @@ export const SETTINGS_SCHEMA = {
1794
1795
  options: STT_MODEL_OPTIONS,
1795
1796
  },
1796
1797
  },
1798
+ "stt.submitTrigger": {
1799
+ type: "enum",
1800
+ values: STT_SUBMIT_TRIGGER_VALUES,
1801
+ default: "never",
1802
+ ui: {
1803
+ tab: "interaction",
1804
+ group: "Speech",
1805
+ label: "Speech-to-Text Submit Trigger",
1806
+ description:
1807
+ "Choose when speech dictation automatically submits: Never, Release (2+ words), Release with complete sentence, or When I Say Submit.",
1808
+ options: STT_SUBMIT_TRIGGER_OPTIONS,
1809
+ },
1810
+ },
1797
1811
 
1798
1812
  // ────────────────────────────────────────────────────────────────────────
1799
1813
  // Context
@@ -4081,7 +4095,7 @@ export const SETTINGS_SCHEMA = {
4081
4095
  group: "Subagents",
4082
4096
  label: "Soft Subagent Request Budget",
4083
4097
  description:
4084
- "Soft per-subagent request budget (assistant requests per run). Crossing it injects one steering notice asking the subagent to wrap up; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/quick_task agents use a lower built-in budget.",
4098
+ "Soft per-subagent request budget (assistant requests per run). Crossing it injects one steering notice asking the subagent to wrap up; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/sonic agents use a lower built-in budget.",
4085
4099
  options: [
4086
4100
  { value: "0", label: "Disabled" },
4087
4101
  { value: "40", label: "40 requests" },
@@ -114,7 +114,13 @@ function formatProfileAsMarkdown(profileJson: string): string {
114
114
  */
115
115
  export async function startCpuProfile(): Promise<ProfilerSession> {
116
116
  const v8 = await import("node:v8");
117
- v8.setFlagsFromString("--allow-natives-syntax");
117
+ try {
118
+ // Enables `%GetOptimizationStatus` and friends when V8 natives are needed
119
+ // for ad-hoc profiling. Best-effort: Bun does not implement
120
+ // `setFlagsFromString` (oven-sh/bun#1702) but the CPU profiler itself
121
+ // works without it, so swallow the error and continue.
122
+ v8.setFlagsFromString("--allow-natives-syntax");
123
+ } catch {}
118
124
 
119
125
  const { Session } = await import("node:inspector/promises");
120
126
  const session = new Session();