jeo-code 0.6.39 → 0.6.41

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,24 @@ 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.41] - 2026-06-21
10
+ _The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6)._
11
+
12
+ ### Fixed
13
+ - **The non-streaming `call()` path no longer false-aborts a long completion at 120s — it now matches the 300s streaming idle window.** v0.6.40's wire heartbeat only re-armed the streaming watchdog (`manager.stream()`); the non-streaming twin `manager.call()` — taken by every `callLlm` WITHOUT an `onToken` consumer (`compaction`, `ralplan`, `deep-interview`, `memory` distill, `goal-verifier`, and subagent/autopilot engine steps) — kept a hard 120s wall cap, so the exact unattended long-reasoning turns the release meant to protect still aborted, exhausted the attempt budget, and stopped the turn. The cap is now 300s, matching `STREAM_IDLE_TIMEOUT_MS`, via a new env-overridable `callTimeoutMs()` helper (`JEO_CALL_TIMEOUT_MS`) mirroring the existing `streamIdleMs()`. A wire heartbeat can't help here — the path collects an opaque buffered body with no per-chunk signal — so a wall clock is the only correct lever; non-streaming `readSse` sites and `call()`'s buffered reads are deliberately left without an idle watchdog (it would false-abort a healthy opaque read).
14
+
15
+ ### Verified
16
+ - `bun run typecheck` clean; full suite **1756 pass / 0 fail** (216 files), including the new `callTimeoutMs` default + `JEO_CALL_TIMEOUT_MS` override parsing tests in `test/round-b.test.ts`.
17
+ - `scripts/mem-probe.ts` (2000 turns × 40 tools): no long-term leak (per-turn slope **−610 bytes/turn**, exit-listeners flat at 1, heap returns to baseline).
18
+ - `scripts/tmux-verify.sh battery`: **6/6 PASSED** on a fresh `jeo --tmux` boot (boot, `/help`, unknown `$skill`, `/agents`, `$ultragoal`, unresolved `/command`).
19
+
20
+ ## [0.6.40] - 2026-06-21
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._
22
+
23
+ ### Fixed
24
+ - **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).
25
+ - **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.
26
+
9
27
  ## [0.6.39] - 2026-06-21
10
28
  _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
29
 
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.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
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
- - **[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.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
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
- - **[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.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
205
+ - **[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
206
  - **[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
207
  - **[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
208
  - **[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
- - **[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.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
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
- - **[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.41",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -126,6 +126,7 @@ export const ConfigSchema = z
126
126
  plan: z.string().optional(),
127
127
  })
128
128
  .optional(),
129
+ gitAutoCommit: z.boolean().optional(),
129
130
  hooks: HookConfigSchema.optional(),
130
131
  })
131
132
  .passthrough();
@@ -95,6 +95,7 @@ export interface Config {
95
95
  * `defaultModel`. Env `JEO_SMOL_MODEL`/`JEO_SLOW_MODEL`/`JEO_PLAN_MODEL` fill gaps.
96
96
  */
97
97
  roles?: { smol?: string; slow?: string; plan?: string };
98
+ gitAutoCommit?: boolean;
98
99
  hooks?: HookConfig;
99
100
  }
100
101
 
@@ -383,16 +383,32 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
383
383
  return { adapter, callOptions, credential: credentialForCall(provider, effective, config, baseUrl), retry };
384
384
  }
385
385
 
386
- /** Hard cap for a single non-streaming provider request (service-readiness: a
387
- * blackholed/unreachable provider must not hang the agent or `jeo team`). */
388
- const DEFAULT_CALL_TIMEOUT_MS = 120_000;
386
+ /** Hard wall-clock cap for a single NON-streaming provider request (service-readiness: a
387
+ * blackholed/unreachable provider must not hang the agent or `jeo team`). Unlike the
388
+ * streaming idle watchdog, this path collects an opaque buffered body (`response.json()`,
389
+ * or an internally-streamed collect in codexResponsesCall / antigravity.call) and exposes
390
+ * no per-chunk signal — so a wall clock is the only lever and a wire heartbeat cannot help.
391
+ * Raised to 300s to match STREAM_IDLE_TIMEOUT_MS: non-interactive turns (callLlm WITHOUT
392
+ * onToken — compaction, ralplan, deep-interview, memory distill, goal-verify, and subagent/
393
+ * autopilot engine steps) route here, and a long reasoning completion legitimately exceeds
394
+ * 120s; too-tight, it aborts an alive call, retries re-incur the same slow request, the
395
+ * attempt budget exhausts, and the turn STOPS — the same false-failure the streaming
396
+ * watchdog guards, on the path the wire heartbeat never reaches. */
397
+ const DEFAULT_CALL_TIMEOUT_MS = 300_000;
389
398
 
390
399
  /** Per-chunk idle cap for streaming: a stream that emits NOTHING for this long is
391
400
  * aborted, but a healthy long generation (chunks keep arriving) runs unbounded —
392
401
  * 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;
402
+ * Set to 300s (not 120s): the wire-level heartbeat already re-arms this watchdog on
403
+ * ANY bytes (keepalive/ping) from remote providers, so this cap now only bites the
404
+ * GENUINELY-silent case — chiefly local backends (Ollama / llama.cpp) whose model
405
+ * load + prompt-eval before the first token emits zero bytes and no keepalive, and
406
+ * can easily exceed 120s on modest hardware or a large context. A too-tight cap there
407
+ * aborts an alive-but-quiet generation, retries re-incur the same slow first byte, the
408
+ * attempt budget exhausts, and the turn STOPS — the exact false-failure this guards.
409
+ * Opt-in override via JEO_STREAM_IDLE_MS for reasoning/local workloads whose silent
410
+ * phase runs longer still; Ctrl-C remains the interactive escape for a truly dead one. */
411
+ const STREAM_IDLE_TIMEOUT_MS = 300_000;
396
412
 
397
413
  /** Combine two abort signals into one. Preserves BOTH even when `AbortSignal.any`
398
414
  * is unavailable (manual fallback), so neither the caller's cancel nor the timeout
@@ -498,6 +514,15 @@ export function streamIdleMs(env?: Record<string, string | undefined>): number {
498
514
  return Number.isFinite(n) && n > 0 ? n : STREAM_IDLE_TIMEOUT_MS;
499
515
  }
500
516
 
517
+ /** Hard wall-clock cap (ms) for a NON-streaming provider call, falling back to the
518
+ * built-in default. Mirrors streamIdleMs so a slow non-interactive turn (callLlm without
519
+ * onToken) can relax the cap via JEO_CALL_TIMEOUT_MS without a code change. */
520
+ export function callTimeoutMs(env?: Record<string, string | undefined>): number {
521
+ const raw = jeoEnv("CALL_TIMEOUT_MS", env);
522
+ const n = raw !== undefined ? parseInt(raw, 10) : NaN;
523
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_CALL_TIMEOUT_MS;
524
+ }
525
+
501
526
  export async function* retryableStream(
502
527
  makeIter: () => AsyncIterator<string>,
503
528
  retry: RetryOptions,
@@ -519,7 +544,7 @@ export function createModelManager(): ModelManager {
519
544
  resolveProvider,
520
545
  async call(messages, options = {}) {
521
546
  const { adapter, callOptions, credential, retry } = await resolveCall(options);
522
- return withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, DEFAULT_CALL_TIMEOUT_MS) }, credential), retry);
547
+ return withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry);
523
548
  },
524
549
  async *stream(messages, options = {}) {
525
550
  const { adapter, callOptions, credential, retry } = await resolveCall(options, "stream");
@@ -531,11 +556,13 @@ export function createModelManager(): ModelManager {
531
556
  // JEO_STREAM_MAX_MS opts in to an OVERALL deadline (round-14): non-interactive
532
557
  // runs can bound a slow-drip stream the per-chunk idle alone never catches.
533
558
  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.
559
+ // Heartbeat: a long server-side "thinking" phase emits no yielded chunk, so the
560
+ // idle watchdog would otherwise look stalled and trip a false idle-stall retry.
561
+ // Two heartbeat sources bump lastActivityAt: (1) reasoning/thinking deltas routed
562
+ // to onReasoning, and (2) onStreamActivity — the wire-level heartbeat fired on ANY
563
+ // bytes from the provider stream (SSE keepalive/ping comments, events that never
564
+ // become a chunk). The watchdog re-arms while ANY activity advances and aborts only
565
+ // a genuinely dead stream (zero bytes for the idle window).
539
566
  let lastActivityAt = Date.now();
540
567
  const bump = () => { lastActivityAt = Date.now(); };
541
568
  const userOnReasoning = callOptions.onReasoning;
@@ -544,6 +571,10 @@ export function createModelManager(): ModelManager {
544
571
  ...callOptions,
545
572
  onReasoning: (delta: string) => { bump(); userOnReasoning?.(delta); },
546
573
  onReasoningStart: () => { bump(); userOnReasoningStart?.(); },
574
+ // Wire-level heartbeat: ANY bytes from the provider stream (SSE keepalive/ping
575
+ // comments, events that never become a chunk) stamp activity, so the idle
576
+ // watchdog re-arms for a connected-but-quiet stream and aborts only a dead one.
577
+ onStreamActivity: bump,
547
578
  };
548
579
  const makeIter = () => {
549
580
  attempt = new AbortController();
@@ -559,7 +590,7 @@ export function createModelManager(): ModelManager {
559
590
  });
560
591
  } else {
561
592
  // Fallback: providers without streaming yield the full response as one chunk.
562
- yield await withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, DEFAULT_CALL_TIMEOUT_MS) }, credential), retry);
593
+ yield await withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry);
563
594
  }
564
595
  },
565
596
  };
@@ -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
@@ -1025,6 +1025,31 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1025
1025
  }
1026
1026
  sessionUsage.turns++;
1027
1027
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
1028
+
1029
+ if (turnConfig.gitAutoCommit) {
1030
+ try {
1031
+ const statusRes = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
1032
+ if (statusRes.exitCode === 0 && statusRes.stdout.toString().trim().length > 0) {
1033
+ Bun.spawnSync(["git", "add", "."], { cwd });
1034
+ const diffRes = Bun.spawnSync(["git", "diff", "--cached", "--name-status"], { cwd, stdout: "pipe", stderr: "ignore" });
1035
+ const changedFiles = diffRes.stdout.toString().trim().split("\n").map(line => line.split("\t")[1]).filter(Boolean);
1036
+ const fileSummary = changedFiles.length > 0 ? ` (modified: ${changedFiles.slice(0, 3).join(", ")}${changedFiles.length > 3 ? ", ..." : ""})` : "";
1037
+ const commitMsg = `[jeo] auto-commit${fileSummary}\n\nPrompt: ${userInput.slice(0, 100)}${userInput.length > 100 ? "..." : ""}`;
1038
+ const commitRes = Bun.spawnSync(["git", "commit", "-m", commitMsg], { cwd });
1039
+ if (commitRes.exitCode === 0) {
1040
+ if (tui) tui.events().onNotice?.(`(auto-committed changes to git)`);
1041
+ else console.log(`(auto-committed changes to git)`);
1042
+ } else {
1043
+ if (tui) tui.events().onNotice?.(`(auto-commit failed: git commit returned non-zero)`);
1044
+ else console.error(`(auto-commit failed: git commit returned non-zero)`);
1045
+ }
1046
+ }
1047
+ } catch (e) {
1048
+ if (tui) tui.events().onNotice?.(`(auto-commit failed: ${e instanceof Error ? e.message : String(e)})`);
1049
+ else console.error(`(auto-commit failed: ${e instanceof Error ? e.message : String(e)})`);
1050
+ }
1051
+ }
1052
+
1028
1053
  return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage };
1029
1054
  };
1030
1055
 
@@ -3349,6 +3374,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3349
3374
  }
3350
3375
  continue;
3351
3376
  }
3377
+ if (input === "/undo") {
3378
+ try {
3379
+ const logRes = Bun.spawnSync(["git", "log", "-1", "--pretty=%B"], { cwd, stdout: "pipe", stderr: "ignore" });
3380
+ if (logRes.exitCode === 0) {
3381
+ const msg = logRes.stdout.toString().trim();
3382
+ if (msg.startsWith("[jeo] auto-commit:")) {
3383
+ const resetRes = Bun.spawnSync(["git", "reset", "--hard", "HEAD~1"], { cwd, stdout: "pipe", stderr: "pipe" });
3384
+ if (resetRes.exitCode === 0) {
3385
+ console.log("(undid the last jeo auto-commit and restored the working tree)");
3386
+ } else {
3387
+ console.log(`! undo failed: ${resetRes.stderr.toString().trim()}`);
3388
+ }
3389
+ } else {
3390
+ console.log("! the last commit was not made by jeo (no '[jeo] auto-commit:' prefix). Use git manually to revert.");
3391
+ }
3392
+ } else {
3393
+ console.log("! undo failed: could not read git log (not a git repo?)");
3394
+ }
3395
+ } catch (err) {
3396
+ console.log(`! undo failed: ${(err as Error).message}`);
3397
+ }
3398
+ continue;
3399
+ }
3352
3400
  if (input === "/goal" || input.startsWith("/goal ")) {
3353
3401
  const arg = input.substring(5).trim();
3354
3402
  if (!arg) {