jeo-code 0.6.39 → 0.6.40

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.40] - 2026-06-21
10
+ _Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn._
11
+
12
+ ### Fixed
13
+ - **A connected-but-quiet stream no longer trips a false `stream idle for <ms>ms (no chunk)` retry — wire-level heartbeat.** The v0.6.39 fix only counted reasoning/thinking deltas as activity, so a model that reasons server-side and streams NO thought tokens (only SSE keepalive/`ping` events, e.g. Anthropic `event: ping`, or events that never become a chunk) still looked stalled and got aborted+retried past the idle window. The SSE reader (`readLines`/`readSse`) now fires an `onStreamActivity` heartbeat on ANY bytes received from the provider — including keepalive comments and events filtered out before becoming a chunk or reasoning delta — threaded through every provider (`anthropic`, `openai`, `openai-responses`, `gemini`, `antigravity`, `ollama`). The idle watchdog re-arms while ANY wire activity advances and aborts only a genuinely dead socket (zero bytes for the idle window).
14
+ - **The default stream-idle window is now 300s (was 120s) so a silent local backend no longer false-aborts and stops the turn.** Root cause of the recurring `stream idle … (no chunk)` halt for local/slow providers: a backend like Ollama / llama.cpp emits ZERO bytes (no keepalive) during model load + prompt-eval before the first token, which on modest hardware or a large context routinely exceeds 120s — the wire heartbeat has nothing to fire on, the watchdog aborts an alive-but-quiet generation, each retry re-incurs the same slow first byte, the attempt budget exhausts, and the turn stops. The cap now only bites a genuinely dead stream after 5 minutes; `JEO_STREAM_IDLE_MS` overrides it and Ctrl-C remains the interactive escape.
15
+
9
16
  ## [0.6.39] - 2026-06-21
10
17
  _A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6)._
11
18
 
package/README.ja.md CHANGED
@@ -200,11 +200,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
200
200
  ## 変更履歴 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
203
204
  - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
205
  - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
205
206
  - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
206
207
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
207
- - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -200,11 +200,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
200
200
  ## 변경 이력 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
203
204
  - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
205
  - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
205
206
  - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
206
207
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
207
- - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -183,6 +183,7 @@ JEO_TUI_ALT_SCREEN=1 # legacy alt-screen turn (default: inline scroll
183
183
  JEO_STEP_BASE=24 # dynamic step budget: rolling base
184
184
  JEO_STEP_HARD_CAP=600 # absolute termination guarantee
185
185
  JEO_STREAM_MAX_MS=300000 # opt-in overall stream deadline (default off; bounds slow-drip streams)
186
+ JEO_STREAM_IDLE_MS=300000 # per-chunk idle cap (default 300s); raise for slow/local backends silent before first token
186
187
  JEO_TOOL_OUTPUT_MAX=4000 # model-visible tool output cap (full output spills to artifacts)
187
188
  ```
188
189
 
@@ -200,11 +201,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
200
201
  ## Changelog
201
202
 
202
203
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
204
+ - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
203
205
  - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
206
  - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
205
207
  - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
206
208
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
207
- - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
208
209
 
209
210
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
211
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -200,11 +200,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
200
200
  ## 更新日志 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
203
204
  - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
205
  - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
205
206
  - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
206
207
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
207
- - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.39",
3
+ "version": "0.6.40",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -390,9 +390,16 @@ const DEFAULT_CALL_TIMEOUT_MS = 120_000;
390
390
  /** Per-chunk idle cap for streaming: a stream that emits NOTHING for this long is
391
391
  * aborted, but a healthy long generation (chunks keep arriving) runs unbounded —
392
392
  * unlike a single wall-clock cap that would kill a long-but-active stream.
393
- * Opt-in override via JEO_STREAM_IDLE_MS for reasoning workloads whose "thinking"
394
- * phase can legitimately emit no visible token for longer than the default. */
395
- const STREAM_IDLE_TIMEOUT_MS = 120_000;
393
+ * Set to 300s (not 120s): the wire-level heartbeat already re-arms this watchdog on
394
+ * ANY bytes (keepalive/ping) from remote providers, so this cap now only bites the
395
+ * GENUINELY-silent case — chiefly local backends (Ollama / llama.cpp) whose model
396
+ * load + prompt-eval before the first token emits zero bytes and no keepalive, and
397
+ * can easily exceed 120s on modest hardware or a large context. A too-tight cap there
398
+ * aborts an alive-but-quiet generation, retries re-incur the same slow first byte, the
399
+ * attempt budget exhausts, and the turn STOPS — the exact false-failure this guards.
400
+ * Opt-in override via JEO_STREAM_IDLE_MS for reasoning/local workloads whose silent
401
+ * phase runs longer still; Ctrl-C remains the interactive escape for a truly dead one. */
402
+ const STREAM_IDLE_TIMEOUT_MS = 300_000;
396
403
 
397
404
  /** Combine two abort signals into one. Preserves BOTH even when `AbortSignal.any`
398
405
  * is unavailable (manual fallback), so neither the caller's cancel nor the timeout
@@ -531,11 +538,13 @@ export function createModelManager(): ModelManager {
531
538
  // JEO_STREAM_MAX_MS opts in to an OVERALL deadline (round-14): non-interactive
532
539
  // runs can bound a slow-drip stream the per-chunk idle alone never catches.
533
540
  let attempt: AbortController | null = null;
534
- // Heartbeat: reasoning/thinking deltas are routed to onReasoning and NEVER yielded
535
- // as a chunk, so a model that "thinks" (streams thought tokens) for longer than the
536
- // idle window would otherwise look stalled and trip a false idle-stall retry. Wrap
537
- // onReasoning/onReasoningStart to stamp lastActivityAt; the idle watchdog re-arms
538
- // while thinking is actively streaming and only aborts a genuinely silent stream.
541
+ // Heartbeat: a long server-side "thinking" phase emits no yielded chunk, so the
542
+ // idle watchdog would otherwise look stalled and trip a false idle-stall retry.
543
+ // Two heartbeat sources bump lastActivityAt: (1) reasoning/thinking deltas routed
544
+ // to onReasoning, and (2) onStreamActivity — the wire-level heartbeat fired on ANY
545
+ // bytes from the provider stream (SSE keepalive/ping comments, events that never
546
+ // become a chunk). The watchdog re-arms while ANY activity advances and aborts only
547
+ // a genuinely dead stream (zero bytes for the idle window).
539
548
  let lastActivityAt = Date.now();
540
549
  const bump = () => { lastActivityAt = Date.now(); };
541
550
  const userOnReasoning = callOptions.onReasoning;
@@ -544,6 +553,10 @@ export function createModelManager(): ModelManager {
544
553
  ...callOptions,
545
554
  onReasoning: (delta: string) => { bump(); userOnReasoning?.(delta); },
546
555
  onReasoningStart: () => { bump(); userOnReasoningStart?.(); },
556
+ // Wire-level heartbeat: ANY bytes from the provider stream (SSE keepalive/ping
557
+ // comments, events that never become a chunk) stamp activity, so the idle
558
+ // watchdog re-arms for a connected-but-quiet stream and aborts only a dead one.
559
+ onStreamActivity: bump,
547
560
  };
548
561
  const makeIter = () => {
549
562
  attempt = new AbortController();
@@ -410,7 +410,7 @@ export const anthropicAdapter: ProviderAdapter = {
410
410
  // + signature_delta(signature). Accumulate per index and emit one ReasoningArtifact per
411
411
  // block on stream end so the signed thought can be replayed (gajae continuity).
412
412
  const thinkBlocks = new Map<number, { text: string; signature?: string }>();
413
- for await (const data of readSse(response.body)) {
413
+ for await (const data of readSse(response.body, options.onStreamActivity)) {
414
414
  let evt: {
415
415
  type?: string;
416
416
  index?: number;
@@ -280,7 +280,7 @@ export const antigravityAdapter: ProviderAdapter = {
280
280
  let yielded = false;
281
281
  let usage: CcaUsage | undefined;
282
282
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
283
- for await (const data of readSse(response.body)) {
283
+ for await (const data of readSse(response.body, options.onStreamActivity)) {
284
284
  let chunk: CcaChunk;
285
285
  try { chunk = JSON.parse(data); } catch { continue; }
286
286
  const thought = thoughtOf(chunk);
@@ -259,7 +259,7 @@ async function* ccaTurn(messages: Message[], options: CallOptions, credential: C
259
259
  let lastEmptyReason: string | undefined;
260
260
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
261
261
  const seenSigs = new Set<string>();
262
- for await (const data of readSse(response.body)) {
262
+ for await (const data of readSse(response.body, options.onStreamActivity)) {
263
263
  let chunk: CcaChunk;
264
264
  try {
265
265
  chunk = JSON.parse(data);
@@ -334,7 +334,7 @@ export const geminiAdapter: ProviderAdapter = {
334
334
  let lastEmptyReason: string | undefined;
335
335
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
336
336
  const seenSigs = new Set<string>();
337
- for await (const data of readSse(response.body)) {
337
+ for await (const data of readSse(response.body, options.onStreamActivity)) {
338
338
  let chunk: GeminiChunk;
339
339
  try {
340
340
  chunk = JSON.parse(data);
@@ -64,7 +64,7 @@ export const ollamaAdapter: ProviderAdapter = {
64
64
  let doneReason: string | undefined;
65
65
  // Route inline <think>…</think> (local reasoning models) to the reasoning channel.
66
66
  const think = createThinkSplitter(options.onReasoning);
67
- for await (const line of readLines(response.body)) {
67
+ for await (const line of readLines(response.body, options.onStreamActivity)) {
68
68
  let chunk: { message?: { content?: string; thinking?: string }; done?: boolean; done_reason?: string; prompt_eval_count?: number; eval_count?: number; total_duration?: number };
69
69
  try {
70
70
  chunk = JSON.parse(line);
@@ -279,7 +279,7 @@ export async function* codexResponsesStream(
279
279
  let yieldedAny = false;
280
280
  let incompleteReason: string | undefined;
281
281
  const toolAcc = new Map<number, { name: string; args: string }>();
282
- for await (const data of readSse(response.body)) {
282
+ for await (const data of readSse(response.body, options.onStreamActivity)) {
283
283
  const ev = parseResponsesEvent(data);
284
284
  if (ev.reasoningDelta) options.onReasoning?.(ev.reasoningDelta);
285
285
  if (ev.reasoningItem) options.onReasoningArtifact?.({ provider: "openai", model: options.model, itemId: ev.reasoningItem.id, encrypted: ev.reasoningItem.encrypted });
@@ -201,7 +201,7 @@ export const openaiAdapter: ProviderAdapter = {
201
201
  // visible answer and onto the reasoning channel. No-op for models that never emit it.
202
202
  const think = createThinkSplitter(options.onReasoning);
203
203
  const toolAcc = new Map<number, { name: string; args: string }>();
204
- for await (const data of readSse(response.body)) {
204
+ for await (const data of readSse(response.body, options.onStreamActivity)) {
205
205
  let chunk: { choices?: { delta?: OpenAIDelta; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
206
206
  try {
207
207
  chunk = JSON.parse(data);
package/src/ai/sse.ts CHANGED
@@ -1,4 +1,7 @@
1
- export async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
1
+ export async function* readLines(
2
+ stream: ReadableStream<Uint8Array>,
3
+ onActivity?: () => void,
4
+ ): AsyncGenerator<string> {
2
5
  const reader = stream.getReader();
3
6
  const decoder = new TextDecoder("utf-8");
4
7
  let buffer = "";
@@ -8,6 +11,13 @@ export async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGener
8
11
  if (done) {
9
12
  break;
10
13
  }
14
+ // Wire-level heartbeat: ANY bytes from the server (including SSE keepalive/ping
15
+ // comments and events that never become a yielded chunk) mark the stream as alive,
16
+ // so the idle watchdog re-arms instead of falsely aborting a connected-but-quiet
17
+ // stream (e.g. a model reasoning server-side that emits only ping events).
18
+ if (value && value.length > 0) {
19
+ onActivity?.();
20
+ }
11
21
  buffer += decoder.decode(value, { stream: true });
12
22
  const parts = buffer.split("\n");
13
23
  buffer = parts.pop() ?? "";
@@ -34,8 +44,11 @@ export async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGener
34
44
  }
35
45
  }
36
46
 
37
- export async function* readSse(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
38
- for await (const line of readLines(stream)) {
47
+ export async function* readSse(
48
+ stream: ReadableStream<Uint8Array>,
49
+ onActivity?: () => void,
50
+ ): AsyncGenerator<string> {
51
+ for await (const line of readLines(stream, onActivity)) {
39
52
  if (line.startsWith("data:")) {
40
53
  let data = line.slice(5);
41
54
  if (data.startsWith(" ")) {
package/src/ai/types.ts CHANGED
@@ -125,6 +125,12 @@ export interface CallOptions {
125
125
  * thoughtSignature / reasoning item id+encrypted). Separate from `onReasoning` (display
126
126
  * text) because these arrive on different SSE events and are opaque replay data. */
127
127
  onReasoningArtifact?: (artifact: ReasoningArtifact) => void;
128
+ /** Internal wire-activity heartbeat: fired on ANY bytes received from the provider
129
+ * stream — including SSE keepalive/ping comments and events that never become a
130
+ * yielded chunk or reasoning delta. Set by the manager's streaming path so the idle
131
+ * watchdog treats a connected-but-quiet stream (e.g. a model reasoning server-side
132
+ * that emits only ping events) as alive. NOT forwarded to user callbacks. */
133
+ onStreamActivity?: () => void;
128
134
  /** NATIVE tool-calling: function declarations the model may call. Present only on the
129
135
  * main agent step (never the prose wrap-up). Adapters with `supportsNativeTools` send
130
136
  * these on the wire and re-serialize the structured tool call back into the engine's