@oh-my-pi/pi-coding-agent 17.0.3 → 17.0.4

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.
@@ -116,11 +116,15 @@ export declare class MnemopiSessionState {
116
116
  beforeAgentStartPrompt(promptText: string): Promise<string | undefined>;
117
117
  recallForCompaction(messages: AgentMessage[]): Promise<string | undefined>;
118
118
  maybeRetainOnAgentEnd(_messages: AgentMessage[]): Promise<void>;
119
- forceRetainCurrentSession(): Promise<void>;
119
+ forceRetainCurrentSession(options?: {
120
+ extract?: boolean;
121
+ }): Promise<void>;
120
122
  retainMessages(messages: Array<{
121
123
  role: string;
122
124
  content: string;
123
- }>, sourceId: string): Promise<void>;
125
+ }>, sourceId: string, options?: {
126
+ extract?: boolean;
127
+ }): Promise<void>;
124
128
  attachSessionListeners(): void;
125
129
  maybeRecallOnAgentStart(): Promise<void>;
126
130
  /**
@@ -139,15 +143,34 @@ export declare class MnemopiSessionState {
139
143
  * otherwise enqueue would report success while leaving the subagent's
140
144
  * retained memories unconsolidated until the parent eventually shuts down
141
145
  * (PR #2327 review).
146
+ *
147
+ * @param options.full - When true, run `sleepAllSessions` on every owned bank
148
+ * (the full cross-session consolidation used by `/memory enqueue`). When
149
+ * false (the default), run only `sleep` on the current session for a
150
+ * lighter, bounded shutdown pass.
151
+ * @param options.sleep - When false, skips the bank sleep step entirely.
152
+ * Used on the interactive shutdown path so `dispose` does not block on
153
+ * synchronous consolidation of old working rows from previous sessions.
154
+ * @param options.extract - When false, the retained transcript is stored but
155
+ * no LLM fact extraction is scheduled. Used on the interactive shutdown path
156
+ * so `dispose` does not block on a fresh LLM round-trip.
142
157
  */
143
- consolidate(): Promise<void>;
158
+ consolidate(options?: {
159
+ full?: boolean;
160
+ extract?: boolean;
161
+ sleep?: boolean;
162
+ }): Promise<void>;
144
163
  /**
145
- * Release the per-session resources. Defaults to running {@link consolidate}
146
- * before closing handles so normal session shutdown promotes working memory
147
- * into long-term storage. Callers that are about to delete the DB files —
148
- * e.g. `mnemopiBackend.clear` pass `{ consolidate: false }` to skip the
149
- * extraction/sleep pass, since spending tokens on memories that will be
150
- * wiped on the next line is wasted work (PR #2327 review).
164
+ * Release the per-session resources. Defaults to running a lighter
165
+ * {@link consolidate} pass before closing handles: it retains the current
166
+ * transcript and flushes in-flight extractions, but skips the synchronous
167
+ * bank sleep so normal session shutdown returns promptly. Full promotion of
168
+ * working memory into long-term storage is still performed by the explicit
169
+ * `/memory enqueue` and backend enqueue paths. Callers that are about to
170
+ * delete the DB files — e.g. `mnemopiBackend.clear` — pass
171
+ * `{ consolidate: false }` to skip the retain/flush pass, since spending
172
+ * tokens on memories that will be wiped on the next line is wasted work
173
+ * (PR #2327 review).
151
174
  *
152
175
  * `timeoutMs` caps how long the consolidate await blocks the caller
153
176
  * (the user-visible `/quit` / `/exit` shutdown path passes this so
@@ -83,7 +83,7 @@ export declare const taskItemSchema: import("arktype/internal/variants/object.ts
83
83
  name?: string | undefined;
84
84
  agent: import("arktype/internal/attributes.ts").Default<string, "task">;
85
85
  task: string;
86
- outputSchema?: unknown;
86
+ outputSchema?: string | boolean | object | null | undefined;
87
87
  schemaMode?: "permissive" | "strict" | undefined;
88
88
  }, {}>;
89
89
  /** Single task item. Fields are optional defensively: args stream in token by token. */
@@ -105,7 +105,7 @@ export declare const taskSchema: import("arktype/internal/variants/object.ts").O
105
105
  name?: string | undefined;
106
106
  agent: import("arktype/internal/attributes.ts").Default<string, "task">;
107
107
  task: string;
108
- outputSchema?: unknown;
108
+ outputSchema?: string | boolean | object | null | undefined;
109
109
  schemaMode?: "permissive" | "strict" | undefined;
110
110
  isolated?: boolean | undefined;
111
111
  }, {}>;
@@ -113,14 +113,14 @@ declare const ALL_TASK_SCHEMAS: readonly [import("arktype/internal/variants/obje
113
113
  name?: string | undefined;
114
114
  agent: import("arktype/internal/attributes.ts").Default<string, "task">;
115
115
  task: string;
116
- outputSchema?: unknown;
116
+ outputSchema?: string | boolean | object | null | undefined;
117
117
  schemaMode?: "permissive" | "strict" | undefined;
118
118
  isolated?: boolean | undefined;
119
119
  }, {}>, import("arktype/internal/variants/object.ts").ObjectType<{
120
120
  name?: string | undefined;
121
121
  agent: import("arktype/internal/attributes.ts").Default<string, "task">;
122
122
  task: string;
123
- outputSchema?: unknown;
123
+ outputSchema?: string | boolean | object | null | undefined;
124
124
  schemaMode?: "permissive" | "strict" | undefined;
125
125
  }, {}>, import("arktype/internal/variants/object.ts").ObjectType<{
126
126
  context: string;
@@ -128,7 +128,7 @@ declare const ALL_TASK_SCHEMAS: readonly [import("arktype/internal/variants/obje
128
128
  name?: string | undefined;
129
129
  agent: import("arktype/internal/attributes.ts").Default<string, "task">;
130
130
  task: string;
131
- outputSchema?: unknown;
131
+ outputSchema?: string | boolean | object | null | undefined;
132
132
  schemaMode?: "permissive" | "strict" | undefined;
133
133
  isolated?: boolean | undefined;
134
134
  }[];
@@ -138,7 +138,7 @@ declare const ALL_TASK_SCHEMAS: readonly [import("arktype/internal/variants/obje
138
138
  name?: string | undefined;
139
139
  agent: import("arktype/internal/attributes.ts").Default<string, "task">;
140
140
  task: string;
141
- outputSchema?: unknown;
141
+ outputSchema?: string | boolean | object | null | undefined;
142
142
  schemaMode?: "permissive" | "strict" | undefined;
143
143
  }[];
144
144
  }, {}>];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.0.3",
4
+ "version": "17.0.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.0.3",
56
- "@oh-my-pi/omp-stats": "17.0.3",
57
- "@oh-my-pi/pi-agent-core": "17.0.3",
58
- "@oh-my-pi/pi-ai": "17.0.3",
59
- "@oh-my-pi/pi-catalog": "17.0.3",
60
- "@oh-my-pi/pi-mnemopi": "17.0.3",
61
- "@oh-my-pi/pi-natives": "17.0.3",
62
- "@oh-my-pi/pi-tui": "17.0.3",
63
- "@oh-my-pi/pi-utils": "17.0.3",
64
- "@oh-my-pi/pi-wire": "17.0.3",
65
- "@oh-my-pi/snapcompact": "17.0.3",
55
+ "@oh-my-pi/hashline": "17.0.4",
56
+ "@oh-my-pi/omp-stats": "17.0.4",
57
+ "@oh-my-pi/pi-agent-core": "17.0.4",
58
+ "@oh-my-pi/pi-ai": "17.0.4",
59
+ "@oh-my-pi/pi-catalog": "17.0.4",
60
+ "@oh-my-pi/pi-mnemopi": "17.0.4",
61
+ "@oh-my-pi/pi-natives": "17.0.4",
62
+ "@oh-my-pi/pi-tui": "17.0.4",
63
+ "@oh-my-pi/pi-utils": "17.0.4",
64
+ "@oh-my-pi/pi-wire": "17.0.4",
65
+ "@oh-my-pi/snapcompact": "17.0.4",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/api-logs": "^0.220.0",
68
68
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -145,7 +145,7 @@ export const mnemopiBackend: MemoryBackend = {
145
145
  state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
146
146
  setMnemopiSessionState(session, state);
147
147
  }
148
- await state?.consolidate();
148
+ await state?.consolidate({ full: true });
149
149
  } catch (error) {
150
150
  logger.warn("Mnemopi: enqueue failed.", { error: String(error) });
151
151
  }
@@ -9,8 +9,7 @@
9
9
  * in either process.
10
10
  */
11
11
 
12
- import type { StandardEmbeddingModel } from "@oh-my-pi/pi-mnemopi/core";
13
- import { loadFastembed } from "@oh-my-pi/pi-mnemopi/core/fastembed-runtime";
12
+ import { defaultLocalModelInitializer, type StandardEmbeddingModel } from "@oh-my-pi/pi-mnemopi/core";
14
13
  import type { MnemopiEmbedModelId, MnemopiEmbedTransport, MnemopiEmbedWorkerInbound } from "./embed-protocol";
15
14
 
16
15
  interface LoadedModel {
@@ -25,12 +24,14 @@ let loaded: Promise<LoadedModel> | null = null;
25
24
  let loadedKey = "";
26
25
 
27
26
  async function loadModel(model: MnemopiEmbedModelId, cacheDir: string | undefined): Promise<LoadedModel> {
28
- const { FlagEmbedding } = await loadFastembed();
27
+ // Route through mnemopi's shared initializer so the worker inherits BOTH
28
+ // cache heals (sidecar re-fetch AND corrupt-model quarantine/retry) —
29
+ // fastembed/onnxruntime still load only in this child address space, the
30
+ // initializer calls loadFastembed() itself.
29
31
  // Cast: `model` arrives as a string from the parent (resolved by
30
- // mnemopi's `fastembedModelName`). Cast to the non-CUSTOM overload's
31
- // argument so TypeScript picks the standard-model branch — the parent
32
- // only ever passes pre-vetted fast-* identifiers.
33
- const instance = await FlagEmbedding.init({
32
+ // mnemopi's `fastembedModelName`); the parent only ever passes pre-vetted
33
+ // fast-* identifiers.
34
+ const instance = await defaultLocalModelInitializer({
34
35
  model: model as StandardEmbeddingModel,
35
36
  cacheDir,
36
37
  showDownloadProgress: false,
@@ -454,18 +454,23 @@ export class MnemopiSessionState {
454
454
  this.lastRetainedTurn = userTurns;
455
455
  }
456
456
 
457
- async forceRetainCurrentSession(): Promise<void> {
457
+ async forceRetainCurrentSession(options: { extract?: boolean } = {}): Promise<void> {
458
458
  if (this.aliasOf) return;
459
459
  const flat = extractMessages(this.session.sessionManager);
460
- await this.retainMessages(flat, this.sessionId);
460
+ await this.retainMessages(flat, this.sessionId, options);
461
461
  this.lastRetainedTurn = flat.filter(message => message.role === "user").length;
462
462
  }
463
463
 
464
- async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise<void> {
464
+ async retainMessages(
465
+ messages: Array<{ role: string; content: string }>,
466
+ sourceId: string,
467
+ options: { extract?: boolean } = {},
468
+ ): Promise<void> {
465
469
  const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
466
470
  if (!transcript) return;
467
471
  const { transcript: extractText } = prepareUserRetentionTranscript(messages);
468
472
  const { transcript: embedText } = prepareEmbeddableRetentionTranscript(messages);
473
+ const shouldExtract = options.extract !== false && extractText !== null;
469
474
  this.rememberInScope(transcript, {
470
475
  source: "coding-agent-transcript",
471
476
  importance: 0.65,
@@ -476,9 +481,9 @@ export class MnemopiSessionState {
476
481
  cwd: this.session.sessionManager.getCwd(),
477
482
  },
478
483
  scope: "bank",
479
- extract: extractText !== null,
480
- extractEntities: extractText !== null,
481
- extractText,
484
+ extract: shouldExtract,
485
+ extractEntities: shouldExtract,
486
+ extractText: shouldExtract ? extractText : null,
482
487
  embedText,
483
488
  veracity: "unknown",
484
489
  memoryType: "episode",
@@ -530,22 +535,42 @@ export class MnemopiSessionState {
530
535
  * otherwise enqueue would report success while leaving the subagent's
531
536
  * retained memories unconsolidated until the parent eventually shuts down
532
537
  * (PR #2327 review).
538
+ *
539
+ * @param options.full - When true, run `sleepAllSessions` on every owned bank
540
+ * (the full cross-session consolidation used by `/memory enqueue`). When
541
+ * false (the default), run only `sleep` on the current session for a
542
+ * lighter, bounded shutdown pass.
543
+ * @param options.sleep - When false, skips the bank sleep step entirely.
544
+ * Used on the interactive shutdown path so `dispose` does not block on
545
+ * synchronous consolidation of old working rows from previous sessions.
546
+ * @param options.extract - When false, the retained transcript is stored but
547
+ * no LLM fact extraction is scheduled. Used on the interactive shutdown path
548
+ * so `dispose` does not block on a fresh LLM round-trip.
533
549
  */
534
- async consolidate(): Promise<void> {
535
- await this.forceRetainCurrentSession();
550
+ async consolidate(options: { full?: boolean; extract?: boolean; sleep?: boolean } = {}): Promise<void> {
551
+ await this.forceRetainCurrentSession({ extract: options.extract });
536
552
  for (const memory of this.scoped.owned) {
537
553
  await memory.flushExtractions();
538
- memory.sleepAllSessions(false);
554
+ if (options.sleep === false) continue;
555
+ if (options.full) {
556
+ memory.sleepAllSessions(false);
557
+ } else {
558
+ memory.sleep(false);
559
+ }
539
560
  }
540
561
  }
541
562
 
542
563
  /**
543
- * Release the per-session resources. Defaults to running {@link consolidate}
544
- * before closing handles so normal session shutdown promotes working memory
545
- * into long-term storage. Callers that are about to delete the DB files —
546
- * e.g. `mnemopiBackend.clear` pass `{ consolidate: false }` to skip the
547
- * extraction/sleep pass, since spending tokens on memories that will be
548
- * wiped on the next line is wasted work (PR #2327 review).
564
+ * Release the per-session resources. Defaults to running a lighter
565
+ * {@link consolidate} pass before closing handles: it retains the current
566
+ * transcript and flushes in-flight extractions, but skips the synchronous
567
+ * bank sleep so normal session shutdown returns promptly. Full promotion of
568
+ * working memory into long-term storage is still performed by the explicit
569
+ * `/memory enqueue` and backend enqueue paths. Callers that are about to
570
+ * delete the DB files — e.g. `mnemopiBackend.clear` — pass
571
+ * `{ consolidate: false }` to skip the retain/flush pass, since spending
572
+ * tokens on memories that will be wiped on the next line is wasted work
573
+ * (PR #2327 review).
549
574
  *
550
575
  * `timeoutMs` caps how long the consolidate await blocks the caller
551
576
  * (the user-visible `/quit` / `/exit` shutdown path passes this so
@@ -569,9 +594,11 @@ export class MnemopiSessionState {
569
594
  closeOwned();
570
595
  return;
571
596
  }
572
- const consolidatePromise = this.consolidate().catch((error: unknown) => {
573
- logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
574
- });
597
+ const consolidatePromise = this.consolidate({ full: false, extract: false, sleep: false }).catch(
598
+ (error: unknown) => {
599
+ logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
600
+ },
601
+ );
575
602
  const { timeoutMs } = options;
576
603
  if (timeoutMs !== undefined && timeoutMs > 0) {
577
604
  const TIMED_OUT = Symbol("mnemopi.dispose.timedOut");
@@ -19,11 +19,17 @@ interface FinalizableBlock {
19
19
  /**
20
20
  * Monotonic content version for blocks that can still mutate *after*
21
21
  * reporting finalized (e.g. `AssistantMessageComponent`: the inline error
22
- * restored at the next turn's `agent_start`, late tool-result images). The
23
- * committed-scrollback render bypass only replays a block's previous rows
24
- * when the version is unchanged; without this signal a post-finalize
25
- * mutation would stay invisible until a global invalidation. Blocks that
26
- * never mutate post-finalize simply omit the method.
22
+ * restored at the next turn's `agent_start`, late tool-result images). While
23
+ * the block's rows are still on screen (not yet fully committed), the
24
+ * committed-scrollback render bypass replays its previous rows only when the
25
+ * version is unchanged; a bump forces a real render so the TUI's
26
+ * committed-prefix audit can observe and re-anchor the change. Once the rows
27
+ * fully commit to native scrollback they are dropped from the local frame
28
+ * (compacted) — a later mutation no longer recommits on an ordinary frame
29
+ * (immutable history the terminal owns; recommitting would duplicate it) and
30
+ * instead rehydrates on the next destructive full replay
31
+ * ({@link "@oh-my-pi/pi-tui".NativeScrollbackReplay}). Blocks that never
32
+ * mutate post-finalize simply omit the method.
27
33
  */
28
34
  getTranscriptBlockVersion?(): number;
29
35
  /**
@@ -124,7 +130,7 @@ interface BlockSegment {
124
130
  sep: number;
125
131
  /** Whether the block reported finalized when this segment was rendered. */
126
132
  finalized: boolean;
127
- /** Safe to drop after commit: produced while finalized, without post-finalize version tracking. */
133
+ /** Safe to drop from the local frame once its rows fully commit to native scrollback: produced while finalized. */
128
134
  compactable: boolean;
129
135
  /** Block version observed when this segment was rendered (see {@link FinalizableBlock}). */
130
136
  version: number | undefined;
@@ -453,7 +459,7 @@ export class TranscriptContainer
453
459
  previous.width === width &&
454
460
  previous.generation === this.#generation);
455
461
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
456
- const compactable = finalized && version === undefined && previous?.finalized !== false;
462
+ const compactable = finalized && previous?.finalized !== false;
457
463
 
458
464
  // Empty (or stripped-to-nothing) children contribute nothing and never
459
465
  // affect spacing. An empty still-live child still gates the commit
@@ -271,10 +271,38 @@ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, ke
271
271
  );
272
272
  }
273
273
 
274
+ /**
275
+ * Cheap synchronous precheck: does this value's tree contain any `blob:sha256:` string?
276
+ * Early-exits on the first hit and allocates no promises, so blob-free entries skip the
277
+ * async {@link resolvePersistedBlobRefs} descent entirely. Conservative — a blob ref in a
278
+ * non-resolved position still returns true, which only costs an extra (no-op) walk.
279
+ */
280
+ function containsBlobRef(value: unknown): boolean {
281
+ if (typeof value === "string") return isBlobRef(value);
282
+ if (Array.isArray(value)) {
283
+ for (const item of value) {
284
+ if (containsBlobRef(item)) return true;
285
+ }
286
+ return false;
287
+ }
288
+ if (typeof value !== "object" || value === null) return false;
289
+ for (const key in value) {
290
+ if (containsBlobRef((value as Record<string, unknown>)[key])) return true;
291
+ }
292
+ return false;
293
+ }
294
+
274
295
  export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
275
- await Promise.all(
276
- entries.filter(entry => entry.type !== "session").map(entry => resolvePersistedBlobRefs(entry, blobStore)),
277
- );
296
+ const pending: Promise<void>[] = [];
297
+ // Interleave precheck + initiation per entry so a positive entry begins resolution at the same
298
+ // relative point as the old filter+map schedule (no scan-all-first pass that could observe a
299
+ // later entry before an earlier resolution mutates it).
300
+ for (const entry of entries) {
301
+ if (entry.type === "session") continue;
302
+ if (!containsBlobRef(entry)) continue;
303
+ pending.push(resolvePersistedBlobRefs(entry, blobStore));
304
+ }
305
+ await Promise.all(pending);
278
306
  }
279
307
 
280
308
  /**
@@ -11,6 +11,8 @@ export interface RecordingHandle {
11
11
  }
12
12
 
13
13
  const isWindows = process.platform === "win32";
14
+ const linuxFFmpegFormats = new Map<string, "pulse" | "alsa">();
15
+ const ffmpegCaptureFlags = ["-hide_banner", "-loglevel", "error", "-nostats"];
14
16
 
15
17
  /**
16
18
  * Returns available recording tools in priority order.
@@ -36,6 +38,36 @@ async function detectWindowsAudioDevice(bin: string): Promise<string> {
36
38
  return audioDevices[0];
37
39
  }
38
40
 
41
+ async function ffmpegInputArgs(bin: string): Promise<string[]> {
42
+ if (isWindows) {
43
+ return ["-f", "dshow", "-i", `audio=${await detectWindowsAudioDevice(bin)}`];
44
+ }
45
+ if (process.platform === "darwin") {
46
+ return ["-f", "avfoundation", "-i", ":default"];
47
+ }
48
+
49
+ let format = linuxFFmpegFormats.get(bin);
50
+ if (!format) {
51
+ const result = await $`${bin} -hide_banner -demuxers`.quiet().nothrow();
52
+ if (result.exitCode !== 0) {
53
+ const stderr = result.stderr.toString().trim();
54
+ throw new Error(
55
+ `Could not inspect ffmpeg input formats (code ${result.exitCode}): ${stderr || "(no output)"}`,
56
+ );
57
+ }
58
+ const demuxers = result.stdout.toString();
59
+ if (/^\s*D\s+(?:d\s+)?pulse(?:\s|$)/m.test(demuxers)) {
60
+ format = "pulse";
61
+ } else if (/^\s*D\s+(?:d\s+)?alsa(?:\s|$)/m.test(demuxers)) {
62
+ format = "alsa";
63
+ } else {
64
+ throw new Error("ffmpeg supports neither PulseAudio nor ALSA input on Linux");
65
+ }
66
+ linuxFFmpegFormats.set(bin, format);
67
+ }
68
+ return ["-f", format, "-i", "default"];
69
+ }
70
+
39
71
  // ── Recording implementations ──────────────────────────────────────
40
72
 
41
73
  async function startSoxRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
@@ -44,7 +76,7 @@ async function startSoxRecording(bin: string, outputPath: string): Promise<Recor
44
76
 
45
77
  const proc = Bun.spawn([bin, ...inputArgs, "-r", "16000", "-c", "1", "-b", "16", "-t", "wav", outputPath], {
46
78
  stdout: "pipe",
47
- stderr: "ignore",
79
+ stderr: "pipe",
48
80
  });
49
81
  await verifyProcessAlive(proc, "sox");
50
82
  return {
@@ -56,48 +88,24 @@ async function startSoxRecording(bin: string, outputPath: string): Promise<Recor
56
88
  }
57
89
 
58
90
  async function startFFmpegRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
59
- let args: string[];
60
- if (isWindows) {
61
- const device = await detectWindowsAudioDevice(bin);
62
- args = [
63
- bin,
64
- "-f",
65
- "dshow",
66
- "-i",
67
- `audio=${device}`,
68
- "-ar",
69
- "16000",
70
- "-ac",
71
- "1",
72
- "-sample_fmt",
73
- "s16",
74
- "-y",
75
- outputPath,
76
- ];
77
- } else if (process.platform === "darwin") {
78
- args = [
79
- bin,
80
- "-f",
81
- "avfoundation",
82
- "-i",
83
- ":default",
84
- "-ar",
85
- "16000",
86
- "-ac",
87
- "1",
88
- "-sample_fmt",
89
- "s16",
90
- "-y",
91
- outputPath,
92
- ];
93
- } else {
94
- args = [bin, "-f", "pulse", "-i", "default", "-ar", "16000", "-ac", "1", "-sample_fmt", "s16", "-y", outputPath];
95
- }
91
+ const args = [
92
+ bin,
93
+ ...ffmpegCaptureFlags,
94
+ ...(await ffmpegInputArgs(bin)),
95
+ "-ar",
96
+ "16000",
97
+ "-ac",
98
+ "1",
99
+ "-sample_fmt",
100
+ "s16",
101
+ "-y",
102
+ outputPath,
103
+ ];
96
104
 
97
105
  const proc = Bun.spawn(args, {
98
106
  stdin: "pipe",
99
107
  stdout: "pipe",
100
- stderr: "ignore",
108
+ stderr: "pipe",
101
109
  });
102
110
  await verifyProcessAlive(proc, "ffmpeg");
103
111
 
@@ -119,7 +127,7 @@ async function startFFmpegRecording(bin: string, outputPath: string): Promise<Re
119
127
  async function startArecordRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
120
128
  const proc = Bun.spawn([bin, "-f", "S16_LE", "-r", "16000", "-c", "1", outputPath], {
121
129
  stdout: "pipe",
122
- stderr: "ignore",
130
+ stderr: "pipe",
123
131
  });
124
132
  await verifyProcessAlive(proc, "arecord");
125
133
  return {
@@ -260,20 +268,19 @@ async function startPowerShellRecording(outputPath: string): Promise<RecordingHa
260
268
 
261
269
  // ── Health check ───────────────────────────────────────────────────
262
270
 
263
- type RecorderProcess = Subprocess<"ignore" | "pipe", "pipe", "ignore">;
271
+ type RecorderProcess = Subprocess<"ignore" | "pipe", "pipe", "pipe">;
264
272
 
265
273
  async function verifyProcessAlive(proc: RecorderProcess, tool: string): Promise<void> {
266
274
  await Bun.sleep(300);
267
275
 
268
276
  const exited = await Promise.race([proc.exited.then(code => code), Bun.sleep(0).then(() => "running" as const)]);
269
-
270
- if (exited !== "running") {
271
- let stderr = "";
272
- if (proc.stderr && typeof proc.stderr !== "number") {
273
- stderr = await new Response(proc.stderr as ReadableStream).text();
274
- }
275
- throw new Error(`${tool} exited immediately (code ${exited}): ${stderr.trim() || "(no output)"}`);
277
+ if (exited === "running") {
278
+ void proc.stderr.pipeTo(new WritableStream<Uint8Array>()).catch(() => {});
279
+ return;
276
280
  }
281
+
282
+ const stderr = await new Response(proc.stderr).text();
283
+ throw new Error(`${tool} exited immediately (code ${exited}): ${stderr.trim() || "(no output)"}`);
277
284
  }
278
285
 
279
286
  // ── Public API ─────────────────────────────────────────────────────
@@ -415,12 +422,18 @@ async function streamingRecorderArgs(recorder: ResolvedRecorder): Promise<string
415
422
  case "arecord":
416
423
  return [bin, "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw", "-"];
417
424
  case "ffmpeg": {
418
- const input = isWindows
419
- ? ["-f", "dshow", "-i", `audio=${await detectWindowsAudioDevice(bin)}`]
420
- : process.platform === "darwin"
421
- ? ["-f", "avfoundation", "-i", ":default"]
422
- : ["-f", "pulse", "-i", "default"];
423
- return [bin, ...input, "-ar", "16000", "-ac", "1", "-f", "s16le", "pipe:1"];
425
+ return [
426
+ bin,
427
+ ...ffmpegCaptureFlags,
428
+ ...(await ffmpegInputArgs(bin)),
429
+ "-ar",
430
+ "16000",
431
+ "-ac",
432
+ "1",
433
+ "-f",
434
+ "s16le",
435
+ "pipe:1",
436
+ ];
424
437
  }
425
438
  case "powershell":
426
439
  throw new Error("PowerShell recorder cannot stream PCM to a pipe");
@@ -439,7 +452,7 @@ async function startStreamingRecordingWithRecorder(
439
452
  ): Promise<StreamingRecordingHandle> {
440
453
  const args = await streamingRecorderArgs(recorder);
441
454
  logger.debug("Starting streaming audio recording", { tool: recorder.tool, bin: recorder.bin });
442
- const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "ignore" });
455
+ const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
443
456
 
444
457
  // Read s16le bytes off stdout, carrying any trailing odd byte across chunk
445
458
  // boundaries so a sample is never split. Runs until the process closes stdout.
package/src/task/types.ts CHANGED
@@ -106,11 +106,14 @@ export interface SubagentLifecyclePayload {
106
106
  /** Display cap for a normalized one-line label (roster line, registry `displayName`, prompt field). */
107
107
  export const LABEL_MAX = 80;
108
108
 
109
+ // Keep this explicit: ArkType serializes `unknown` as a boolean subschema, which llama.cpp grammars reject.
110
+ const outputSchemaInputSchema = type("object | boolean | string | null");
111
+
109
112
  export const taskItemSchema = type({
110
113
  "name?": "string",
111
114
  agent: "string = 'task'",
112
115
  task: "string",
113
- "outputSchema?": "unknown",
116
+ "outputSchema?": outputSchemaInputSchema,
114
117
  "schemaMode?": '"permissive" | "strict"',
115
118
  "+": "delete",
116
119
  });
@@ -118,7 +121,7 @@ const taskItemSchemaIsolated = type({
118
121
  "name?": "string",
119
122
  agent: "string = 'task'",
120
123
  task: "string",
121
- "outputSchema?": "unknown",
124
+ "outputSchema?": outputSchemaInputSchema,
122
125
  "schemaMode?": '"permissive" | "strict"',
123
126
  "isolated?": "boolean",
124
127
  "+": "delete",
@@ -144,7 +147,7 @@ export const taskSchema = type({
144
147
  "name?": "string",
145
148
  agent: "string = 'task'",
146
149
  task: "string",
147
- "outputSchema?": "unknown",
150
+ "outputSchema?": outputSchemaInputSchema,
148
151
  "schemaMode?": '"permissive" | "strict"',
149
152
  "isolated?": "boolean",
150
153
  "+": "delete",
@@ -153,7 +156,7 @@ const taskSchemaNoIsolation = type({
153
156
  "name?": "string",
154
157
  agent: "string = 'task'",
155
158
  task: "string",
156
- "outputSchema?": "unknown",
159
+ "outputSchema?": outputSchemaInputSchema,
157
160
  "schemaMode?": '"permissive" | "strict"',
158
161
  "+": "delete",
159
162
  });
@@ -197,7 +200,7 @@ function createTaskSchema(options: {
197
200
  "name?": "string",
198
201
  agent,
199
202
  task: "string",
200
- "outputSchema?": "unknown",
203
+ "outputSchema?": outputSchemaInputSchema,
201
204
  "schemaMode?": '"permissive" | "strict"',
202
205
  "isolated?": "boolean",
203
206
  "+": "delete",
@@ -212,7 +215,7 @@ function createTaskSchema(options: {
212
215
  "name?": "string",
213
216
  agent,
214
217
  task: "string",
215
- "outputSchema?": "unknown",
218
+ "outputSchema?": outputSchemaInputSchema,
216
219
  "schemaMode?": '"permissive" | "strict"',
217
220
  "+": "delete",
218
221
  });
@@ -227,7 +230,7 @@ function createTaskSchema(options: {
227
230
  "name?": "string",
228
231
  agent,
229
232
  task: "string",
230
- "outputSchema?": "unknown",
233
+ "outputSchema?": outputSchemaInputSchema,
231
234
  "schemaMode?": '"permissive" | "strict"',
232
235
  "isolated?": "boolean",
233
236
  "+": "delete",
@@ -237,7 +240,7 @@ function createTaskSchema(options: {
237
240
  "name?": "string",
238
241
  agent,
239
242
  task: "string",
240
- "outputSchema?": "unknown",
243
+ "outputSchema?": outputSchemaInputSchema,
241
244
  "schemaMode?": '"permissive" | "strict"',
242
245
  "+": "delete",
243
246
  });