jeo-code 0.7.7 → 0.7.9

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,36 @@ 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.7.9] - 2026-06-24
10
+ _Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch._
11
+
12
+ ### Added
13
+ - **Ollama requests send an explicit `num_ctx` so the prompt fits the loaded window.** Ollama loads a model with its small *server* default context (~2048/4096), ignoring the model's advertised `context_length`, unless the request supplies `num_ctx` — so jeo's large system prompt (tool protocol + AGENTS context) overflowed it and every turn died with HTTP 400 "the conversation no longer fits the model's context window." `resolveOllamaNumCtx` (`src/ai/providers/ollama.ts`) now resolves a window with precedence per-call/config → `OLLAMA_NUM_CTX` → `OLLAMA_CONTEXT_LENGTH` → `16384` default (zero/negative/non-numeric ignored) and threads it onto the `/api/chat` `options`. New `ollamaNumCtx` config field (`src/agent/state.ts`, `config-schema.ts`) + `numCtx` `CallOptions` (`src/ai/types.ts`) wired through `model-manager.ts`. The previously-missing `lmstudioBaseUrl` schema key is added too.
14
+ - **Stale `jeo` tmux REPL sessions are reaped on launch.** Each detached, long-idle `jeo launch` REPL pins tens of MB forever, so over days they pile up and aggregate RSS climbs. `selectReapableTmuxSessions`/`reapStaleTmuxSessions` (`src/commands/launch/tmux.ts`) sweep only jeo-owned (`@jeo-profile` marker), unattached, long-idle sessions — never the one just created — on each launch; opt out with `JEO_TMUX_REAP=0`. Abandoned REPLs persist their transcript to `.jeo/sessions/` and stay `--resume`-able, so reaping discards only the dead in-memory view.
15
+
16
+ ### Fixed
17
+ - **LM Studio reasoning replies on the `reasoning` channel are recovered instead of dying empty.** Local OpenAI-compatible servers (LM Studio, …) running reasoning models routinely emit the WHOLE reply — including the `{"tool":…}` JSON — into `reasoning_content`/`reasoning` while leaving `content` empty, which otherwise died as a "returned no content" turn. The OpenAI adapter now recovers the answer from the reasoning channel (stripping leaked reasoning/tool-call scaffolding) before failing.
18
+ - **GGUF chat templates that can't render the `tools` array are retried with native tools stripped.** A jinja template error from passing the `tools` array (a common LM Studio 400) is detected (`isUnsupportedToolsError`) and the call is retried with native tools removed (`stripNativeTools`), falling back to JSON-in-prose tool calling. The strip path deliberately does *not* inject `response_format: json_object` (LM Studio rejects it — only `json_schema`/`text`).
19
+ - **The effective-credential picker prefers a verified OAuth login over a stray API key.** `effectiveCredential` (`src/ai/provider-status.ts`) now keeps the user's explicit OAuth login as the working path whenever the bundled adapter serves it end-to-end; an API key only wins for OpenAI OAuth (Codex-only) or an OAuth backend not yet verified end-to-end — mirroring the real call path instead of always letting the API key shadow OAuth.
20
+ - **The OAuth success tab auto-closes with a clear "you can close" hint.** The login-complete callback page now shows a 3-second countdown and calls `window.close()` (`src/auth/callback-server.ts`), with an explicit manual-close hint for OS-launched tabs the script can't close, and a clearer "return to the terminal — jeo is ready" message.
21
+
22
+ ### Verified
23
+ - `bun run typecheck` clean; full suite green — 1952 pass / 0 fail across 232 files — including the new `test/ollama-num-ctx.test.ts` (`num_ctx` precedence + on-wire send), `test/openai-unsupported-tools.test.ts`, `test/provider-empty-completion.test.ts`, `test/tmux-reap.test.ts`, and updated provider-status/oauth/model-manager suites. Live Ollama probe confirmed: a ~6000-token prompt that returned HTTP 400 under the default window now succeeds with the explicit `num_ctx`.
24
+
25
+
26
+ ## [0.7.8] - 2026-06-24
27
+ _Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach._
28
+
29
+ ### Fixed
30
+ - **The detached memory-distill worker always terminates itself.** `runMemoryDistillCommand` now wraps its work in `try/finally` and calls an injected `exit` (defaulting to `process.exit`) on every path — success, broken payload, and missing payload. `distillSessionMemory` caps the LLM call with a `Promise.race` timeout, but the race only *rejects*; it never aborts the underlying fetch, so a stalled provider socket kept the child's event loop alive and the `jeo memory-distill` worker lingered forever — one orphan per session, each pinning the full transcript in RSS (the observed "jeo bun" CPU/memory creep). An explicit exit closes the socket and reclaims the process.
31
+ - **The live reasoning/output block re-wraps only its trailing window.** The wrap memo in `app.ts` keyed on the full (up-to-hundreds-of-KB) buffer, so every 120 ms tick and every stream delta copied + compared the whole growing string — O(len) per frame, O(len²) over a long reasoning/tool stream (the streaming slowdown). `liveBlockWrapKey` now slices the ≤16 KB tail first, then keys + wraps that: identical visible output, O(tail) per frame regardless of total size.
32
+ - **The team loop no longer reprints the todo guide every iteration.** `runTeamEngine` printed `formatRalphTodoGuide` once per task — O(N²) lines that climbed the embedded-terminal renderer's CPU on a long task list. The guide prints once up front; per-task progress lines carry the rest.
33
+ - **The readline caret stays aligned after an image attach.** Both the paste and Ctrl+V image-attach paths now call readline's internal `_refreshLine()` after swapping the `[image #n]` tag in (mirroring the slash/tab-completion accept paths), so the stale row/cursor model no longer offsets the real caret several columns on the next keystroke.
34
+
35
+ ### Verified
36
+ - `bun run typecheck` clean; full suite green — 1914 pass / 0 fail across 229 files — including the new `runMemoryDistillCommand` always-exits assertions (success + no-payload paths) and the `liveBlockWrapKey` bounded-key / tail-collision tests.
37
+
38
+
9
39
  ## [0.7.7] - 2026-06-23
10
40
  _Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers._
11
41
 
package/README.ja.md CHANGED
@@ -193,11 +193,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
193
193
  ## 変更履歴 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
197
+ - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
196
198
  - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
197
199
  - **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
198
200
  - **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
199
- - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
200
- - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -193,11 +193,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
193
193
  ## 변경 이력 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
197
+ - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
196
198
  - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
197
199
  - **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
198
200
  - **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
199
- - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
200
- - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -195,11 +195,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
195
195
  ## Changelog
196
196
 
197
197
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
198
+ - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
199
+ - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
198
200
  - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
199
201
  - **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
200
202
  - **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
201
- - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
202
- - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
203
203
 
204
204
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
205
205
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -193,11 +193,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
193
193
  ## 更新日志 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
197
+ - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
196
198
  - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
197
199
  - **[0.7.6]** (2026-06-23) — Release & docs plumbing: the running version's release notes now travel inside the `--compile` single binary (so `/whats-new` works offline and from a global install), the in-TUI markdown table renderer stops splitting on escaped `\|` pipes, and the release pipeline gains a cross-compiled standalone-binary matrix — macOS/Linux **plus a first-class Windows x64 `.exe`** — built on one Linux runner and auto-attached to each GitHub release.
198
200
  - **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
199
- - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
200
- - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -64,5 +64,6 @@
64
64
  "playwright": "^1.61.0",
65
65
  "typescript": "^5.6.3",
66
66
  "ws": "^8.21.0"
67
- }
67
+ },
68
+ "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
68
69
  }
@@ -68,7 +68,9 @@ export const ConfigSchema = z
68
68
  })
69
69
  .optional(),
70
70
  ollamaBaseUrl: z.string().optional(),
71
+ ollamaNumCtx: z.number().int().positive().optional(),
71
72
  openaiBaseUrl: z.string().optional(),
73
+ lmstudioBaseUrl: z.string().optional(),
72
74
  defaultModel: z.string().min(1),
73
75
  theme: z.string().optional(),
74
76
  thinkingLevel: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(),
@@ -876,21 +876,37 @@ export async function spawnDetachedDistill(
876
876
  }
877
877
 
878
878
  /** CLI worker for the detached child: payload → distill → cleanup. Silent by design. */
879
- export async function runMemoryDistillCommand(args: string[]): Promise<void> {
880
- const payloadPath = (args[0] ?? "").trim();
881
- if (!payloadPath) return;
879
+ export async function runMemoryDistillCommand(
880
+ args: string[],
881
+ // Injected for tests; the REAL detached worker must terminate ITSELF. distillSessionMemory
882
+ // caps the LLM call with a Promise.race timeout, but the race only REJECTS — it never aborts
883
+ // the underlying fetch. A stalled provider socket therefore stays open, the worker's event
884
+ // loop never drains, and the `jeo memory-distill` child lingers forever — one orphan per
885
+ // session, each pinning the full transcript in RSS. That is the observed "jeo bun" pileup
886
+ // (CPU/memory creeping up over time). An explicit exit closes the socket and reclaims it.
887
+ exit: (code: number) => void = (code) => process.exit(code),
888
+ ): Promise<void> {
882
889
  try {
883
- const payloadContent = await fs.readFile(payloadPath, "utf-8");
884
- const payload = JSON.parse(payloadContent) as { model?: string; messages?: Message[] };
885
- const bundleDir = path.join(process.cwd(), ".jeo", "memory");
886
- await saveRawPayload(bundleDir, payload);
887
- if (Array.isArray(payload.messages)) {
888
- await distillSessionMemory(payload.messages, process.cwd(), { model: payload.model });
890
+ const payloadPath = (args[0] ?? "").trim();
891
+ if (!payloadPath) return;
892
+ try {
893
+ const payloadContent = await fs.readFile(payloadPath, "utf-8");
894
+ const payload = JSON.parse(payloadContent) as { model?: string; messages?: Message[] };
895
+ const bundleDir = path.join(process.cwd(), ".jeo", "memory");
896
+ await saveRawPayload(bundleDir, payload);
897
+ if (Array.isArray(payload.messages)) {
898
+ await distillSessionMemory(payload.messages, process.cwd(), { model: payload.model });
899
+ }
900
+ } catch {
901
+ // best-effort — a broken payload must not leave error noise in a detached child
902
+ } finally {
903
+ await fs.unlink(payloadPath).catch(() => {});
889
904
  }
890
- } catch {
891
- // best-effort — a broken payload must not leave error noise in a detached child
892
905
  } finally {
893
- await fs.unlink(payloadPath).catch(() => {});
906
+ // Guaranteed on every path (incl. the no-payload early return and a distill timeout):
907
+ // never leave the detached worker alive waiting on a dangling fetch.
908
+ exit(0);
894
909
  }
895
910
  }
896
911
 
912
+
@@ -39,6 +39,8 @@ export interface Config {
39
39
  oauth?: Partial<Record<AuthProvider, string | StoredOAuth>>;
40
40
  /** Base URL for the local Ollama server (keyless). */
41
41
  ollamaBaseUrl?: string;
42
+ /** Ollama context window (`num_ctx`); overrides the server's small default. Env: OLLAMA_NUM_CTX. */
43
+ ollamaNumCtx?: number;
42
44
  /** Base URL override for OpenAI-compatible providers (vLLM, llama-cpp-server, ...). */
43
45
  openaiBaseUrl?: string;
44
46
  /** Base URL for the local LM Studio server (keyless, OpenAI-compatible). */
@@ -805,7 +805,7 @@ async function spawnTextWithTimeout(
805
805
  timeoutMs = 60_000,
806
806
  ): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
807
807
  // stdin detached so a command that reads stdin gets EOF instead of blocking.
808
- const proc = Bun.spawn(cmd, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
808
+ const proc = Bun.spawn(cmd, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...process.env, MSYS: "noglob" } });
809
809
  let timedOut = false;
810
810
  let killTimer: ReturnType<typeof setTimeout> | undefined;
811
811
  const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
@@ -863,7 +863,8 @@ export async function findTool(
863
863
  const prunedDirs = new Set([...IGNORED_DIRS, ...gi.dirs]);
864
864
  const fileGlobs = gi.fileGlobs.map(g => new Bun.Glob(g));
865
865
  const matches: string[] = [];
866
- for await (const rel of new Bun.Glob(globPattern).scan({ cwd, onlyFiles: true })) {
866
+ for await (const rawRel of new Bun.Glob(globPattern).scan({ cwd, onlyFiles: true })) {
867
+ const rel = rawRel.replace(/\\/g, "/");
867
868
  const segs = rel.split("/");
868
869
  if (segs.some(seg => prunedDirs.has(seg))) continue;
869
870
  const base = segs[segs.length - 1] ?? rel;
@@ -252,12 +252,25 @@ export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "s
252
252
  return opts;
253
253
  }
254
254
 
255
+ /**
256
+ * Whether the bundled adapter can serve this provider+model over OAuth end-to-end.
257
+ * OpenAI OAuth (ChatGPT/Codex) only serves Codex models, so any other OpenAI model
258
+ * must use an API key. A provider whose OAuth backend is not verified end-to-end
259
+ * cannot serve any model over OAuth. Everything else (Anthropic Messages, Gemini /
260
+ * Antigravity Cloud Code Assist) is served end-to-end.
261
+ */
262
+ function oauthServesModel(provider: AuthProvider, model: string): boolean {
263
+ if (provider === "openai") return CODEX_MODELS.includes(model);
264
+ if (isOAuthProvider(provider) && OAUTH_FLOW_REGISTRY[provider].verifiedEndToEnd === false) return false;
265
+ return true;
266
+ }
267
+
255
268
  /**
256
269
  * Pick the credential to actually use for a provider call / live discovery.
257
- * An API key is the broader, documented path, so it wins whenever present.
258
- * Every bundled OAuth flow is now served end-to-end (Anthropic Messages,
259
- * OpenAI ChatGPT/Codex Responses, Gemini/Antigravity Cloud Code Assist); the
260
- * guard below only fires for a future flow that ships before its adapter.
270
+ * OAuth is the user's explicit login, so it wins whenever the bundled adapter can
271
+ * serve the requested model over OAuth — even when an API key is also configured
272
+ * (e.g. a leftover env var). Only when OAuth cannot serve the model do we fall back
273
+ * to the API key, and only then surface the OAuth-incompatibility error.
261
274
  */
262
275
  export function effectiveCredentialForProvider(
263
276
  provider: AuthProvider,
@@ -266,6 +279,7 @@ export function effectiveCredentialForProvider(
266
279
  model: string,
267
280
  ): Credential {
268
281
  if (credential.kind === "oauth") {
282
+ if (oauthServesModel(provider, model)) return credential;
269
283
  const apiKey = config.providers[provider];
270
284
  if (apiKey) return { kind: "api_key", provider, token: apiKey };
271
285
  if (isOAuthProvider(provider) && OAUTH_FLOW_REGISTRY[provider].verifiedEndToEnd === false) {
@@ -328,6 +342,7 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
328
342
  maxTokens: options.maxTokens ?? thinkingMaxTokens(config.thinkingLevel),
329
343
  jsonMode: options.jsonMode,
330
344
  baseUrl,
345
+ numCtx: options.numCtx ?? (provider === "ollama" ? (config as { ollamaNumCtx?: number }).ollamaNumCtx : undefined),
331
346
  onUsage: options.onUsage,
332
347
  signal: options.signal,
333
348
  reasoningEffort: options.reasoningEffort ?? thinkingToReasoningEffort(config.thinkingLevel),
@@ -81,11 +81,17 @@ function configuredCredential(provider: AuthProvider, cfg: Config): Credential {
81
81
  return { kind: "none", provider };
82
82
  }
83
83
 
84
- /** Match the real call path: API keys are broader and win whenever both key + OAuth exist. */
84
+ /** Mirror the real call path: OAuth (the user's explicit login) wins whenever the
85
+ * bundled adapter serves it end-to-end. OpenAI OAuth only serves Codex models, and an
86
+ * OAuth backend not verified end-to-end can't serve calls, so an API key stays the
87
+ * working path in those cases — otherwise the stored OAuth credential is preferred. */
85
88
  function effectiveCredential(provider: AuthProvider, cred: Credential, cfg: Config): Credential {
86
89
  const key = cfg.providers?.[provider];
87
- if (cred.kind === "oauth" && key) return { kind: "api_key", provider, token: key };
88
- return cred;
90
+ if (cred.kind !== "oauth" || !key) return cred;
91
+ const oauthGeneral =
92
+ provider !== "openai" &&
93
+ !(isOAuthProvider(provider) && OAUTH_FLOW_REGISTRY[provider].verifiedEndToEnd === false);
94
+ return oauthGeneral ? cred : { kind: "api_key", provider, token: key };
89
95
  }
90
96
 
91
97
 
@@ -13,6 +13,26 @@ export function normalizeOllamaBaseUrl(baseUrl?: string): string {
13
13
  return (/^https?:\/\//i.test(v) ? v : `http://${v}`).replace(/\/$/, "");
14
14
  }
15
15
 
16
+ /**
17
+ * Default Ollama context window when the prompt would otherwise overflow.
18
+ *
19
+ * Ollama loads every model with a SERVER default `num_ctx` (historically 2048/4096),
20
+ * IGNORING the model's advertised `context_length`, unless the request supplies one.
21
+ * jeo's system prompt + tool protocol + AGENTS context routinely exceeds that, so the
22
+ * request 400s with "context window" even for a model that natively supports 128k.
23
+ * We therefore send an explicit `num_ctx`. Precedence: per-call/config value →
24
+ * `OLLAMA_NUM_CTX`/`OLLAMA_CONTEXT_LENGTH` env → a sane 16384 default. Lower it on
25
+ * memory-constrained hosts (KV-cache is allocated for the full window).
26
+ */
27
+ export const DEFAULT_OLLAMA_NUM_CTX = 16384;
28
+
29
+ export function resolveOllamaNumCtx(explicit?: number): number {
30
+ if (typeof explicit === "number" && explicit > 0) return Math.floor(explicit);
31
+ const env = process.env.OLLAMA_NUM_CTX ?? process.env.OLLAMA_CONTEXT_LENGTH;
32
+ const parsed = env ? Number.parseInt(env, 10) : NaN;
33
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OLLAMA_NUM_CTX;
34
+ }
35
+
16
36
  function ollamaRequest(messages: Message[], options: CallOptions, stream: boolean): { url: string; body: string } {
17
37
  const model = options.model.startsWith("ollama/") ? options.model.slice(7) : options.model;
18
38
  const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
@@ -28,7 +48,7 @@ function ollamaRequest(messages: Message[], options: CallOptions, stream: boolea
28
48
  model,
29
49
  messages: chatMessages,
30
50
  stream,
31
- options: { temperature: options.temperature ?? 0.2, num_predict: options.maxTokens ?? 4000 },
51
+ options: { temperature: options.temperature ?? 0.2, num_predict: options.maxTokens ?? 4000, num_ctx: resolveOllamaNumCtx(options.numCtx) },
32
52
  };
33
53
  if (options.jsonMode) payload.format = "json";
34
54
  const base = normalizeOllamaBaseUrl(options.baseUrl);
@@ -4,7 +4,7 @@ import { readSse } from "../sse";
4
4
  import { providerHttpError } from "./errors";
5
5
  import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
- import { createThinkSplitter } from "../think-tags";
7
+ import { createThinkSplitter, stripLeakedReasoningTags } from "../think-tags";
8
8
 
9
9
  /** True for OpenAI reasoning models (o-series + gpt-5+ family). Digit-count agnostic
10
10
  * (gpt-6/o10 stay reasoning). Strips the `openai/` routing prefix first. */
@@ -57,17 +57,25 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
57
57
  // and reject temperature; classic chat models (gpt-4o, …) take max_tokens + temperature.
58
58
  // Digit-count agnostic (gpt-6/o10 stay reasoning) — mirrors inferCatalogMetadata.
59
59
  const isReasoning = isOpenAIReasoningModel(model);
60
+ // A custom baseUrl means a local / OpenAI-compatible backend (LM Studio, llama.cpp, vLLM,
61
+ // Ollama's OpenAI shim, …). Local reasoning models (DeepSeek-R1, Qwen3, …) routinely burn
62
+ // thousands of tokens INSIDE <think> before emitting the answer, so the cloud-tuned 4000
63
+ // default truncates them at finish_reason=length with an EMPTY content. There's no
64
+ // per-token cost locally, so give them real headroom to both think and answer.
65
+ const isLocalEndpoint = Boolean(options.baseUrl || (process.env.OPENAI_BASE_URL && !/\bapi\.openai\.com\b/.test(process.env.OPENAI_BASE_URL)));
66
+ const defaultMaxTokens = isLocalEndpoint ? 16000 : 4000;
60
67
  const payload: Record<string, unknown> = {
61
68
  model,
62
69
  messages: openaiMessages,
63
70
  };
64
71
  if (isReasoning) {
65
- payload.max_completion_tokens = options.maxTokens ?? 4000;
72
+ payload.max_completion_tokens = options.maxTokens ?? defaultMaxTokens;
66
73
  if (options.reasoningEffort) payload.reasoning_effort = options.reasoningEffort;
67
74
  } else {
68
75
  payload.temperature = options.temperature ?? 0.2;
69
- payload.max_tokens = options.maxTokens ?? 4000;
76
+ payload.max_tokens = options.maxTokens ?? defaultMaxTokens;
70
77
  }
78
+
71
79
  // gjc parity — enable NATIVE reasoning per the backend's thinking format so the model
72
80
  // actually emits reasoning (otherwise OpenRouter/Qwen/z.ai stay silent and the TUI has
73
81
  // nothing to show). `reasoning_effort` (OpenAI-style) only suits o-series/gpt-5; other
@@ -101,6 +109,82 @@ function emptyCompletionError(finishReason: string | undefined): Error {
101
109
  return new Error(`OpenAI returned no content${finishReason ? ` (finish_reason=${finishReason})` : ""}${hint}.`);
102
110
  }
103
111
 
112
+ /** Scan `text` for the first BALANCED, syntactically valid JSON object that carries a
113
+ * `tool`/`tools` key — i.e. a complete jeo tool envelope. Brace-depth aware and string/
114
+ * escape safe so a `{` inside a JSON string never miscounts. Returns the raw object
115
+ * substring, or "" if none is complete. Used to salvage a truncated (finish_reason=length)
116
+ * reasoning stream when the model finished the actionable JSON before it ran out of budget. */
117
+ function firstCompleteToolEnvelope(text: string): string {
118
+ for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
119
+ let depth = 0, inStr = false, esc = false;
120
+ for (let i = start; i < text.length; i++) {
121
+ const ch = text[i];
122
+ if (esc) { esc = false; continue; }
123
+ if (ch === "\\") { esc = true; continue; }
124
+ if (ch === '"') { inStr = !inStr; continue; }
125
+ if (inStr) continue;
126
+ if (ch === "{") depth++;
127
+ else if (ch === "}" && --depth === 0) {
128
+ const candidate = text.slice(start, i + 1);
129
+ try {
130
+ const obj = JSON.parse(candidate) as Record<string, unknown>;
131
+ if (obj && (typeof obj.tool === "string" || Array.isArray(obj.tools))) return candidate;
132
+ } catch { /* not valid JSON — keep scanning from the next `{` */ }
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ return "";
138
+ }
139
+
140
+ /** Recover a usable answer from the reasoning channel when `content` came back empty.
141
+ * Local OpenAI-compatible servers (LM Studio, llama.cpp, …) running reasoning models
142
+ * frequently route the ENTIRE reply — including the `{"tool":…}` JSON tool call — into
143
+ * `reasoning_content`/`reasoning` (or an inline `<think>…</think>` block) and leave
144
+ * `content` empty, which would otherwise kill the turn with "OpenAI returned no content".
145
+ *
146
+ * Strips leaked think/scaffolding markup and returns the recovered text when the turn ended
147
+ * normally (no `finish_reason`, or `stop`). On a `length` finish the reasoning is truncated,
148
+ * but a thinking model often COMPLETES the JSON tool envelope before it runs out of budget —
149
+ * so we still salvage that complete envelope when present, and otherwise surface the
150
+ * actionable budget error. `content_filter` is never recovered (the reply was blocked).
151
+ * Returns "" when nothing is recoverable. */
152
+ function recoverFromReasoning(reasoning: string, finishReason: string | undefined): string {
153
+ if (!reasoning) return "";
154
+ if (finishReason === "content_filter") return "";
155
+ if (finishReason === "length") return firstCompleteToolEnvelope(reasoning);
156
+ return stripLeakedReasoningTags(reasoning);
157
+ }
158
+
159
+ /** A 400 caused by the request's `tools` array, not by the conversation. Many local
160
+ * OpenAI-compatible servers (LM Studio, llama.cpp, older vLLM) ship GGUF chat templates
161
+ * with no tool-calling support; given a `tools` array they fail to render the prompt
162
+ * (LM Studio: `Error rendering prompt with jinja template …`) and 400 the whole turn.
163
+ * jeo ALSO states the JSON-in-prose tool protocol in every system prompt, so we can drop
164
+ * native tools and let the model drive tools via canonical JSON rather than dying. Gated
165
+ * on the request actually carrying tools (a 400 without tools is a real error) plus an
166
+ * error body that points at the template/tools/function machinery. */
167
+ export function isUnsupportedToolsError(requestBody: string, errorBody: string): boolean {
168
+ let parsed: Record<string, unknown>;
169
+ try { parsed = JSON.parse(requestBody); } catch { return false; }
170
+ if (!Array.isArray(parsed.tools) || parsed.tools.length === 0) return false;
171
+ return /jinja|template|tool|function[_ ]?call|does not support|not supported/i.test(errorBody);
172
+ }
173
+
174
+ /** Rebuild an OpenAI request body with native tools removed so a backend whose chat
175
+ * template can't render them stops 400ing. The model still returns a parseable tool
176
+ * envelope via the JSON-in-prose protocol jeo states in every system prompt. We do NOT
177
+ * inject `response_format: json_object` — some LM Studio builds reject it ("must be
178
+ * 'json_schema' or 'text'"), and the prompt already mandates strict JSON. Every other
179
+ * field (stream, stream_options, messages, …) is preserved. */
180
+ export function stripNativeTools(requestBody: string): string {
181
+ const parsed = JSON.parse(requestBody) as Record<string, unknown>;
182
+ delete parsed.tools;
183
+ delete parsed.tool_choice;
184
+ return JSON.stringify(parsed);
185
+ }
186
+
187
+
104
188
  /** A streamed `choices[].delta`. `reasoning` is `unknown` because OpenAI-compatible
105
189
  * servers disagree on its shape: a plain string (OpenRouter/xAI), an object
106
190
  * `{ text|content }`, or absent (the `reasoning_details[]` array carries it instead). */
@@ -152,16 +236,34 @@ export const openaiAdapter: ProviderAdapter = {
152
236
  } catch { /* /responses unsupported for this model/account — fall through to chat */ }
153
237
  }
154
238
  const { url, headers, body } = openaiRequest(messages, options, credential, false);
155
- const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
239
+ let response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
240
+ if (response.status === 400) {
241
+ // Local OpenAI-compatible servers (LM Studio, llama.cpp, …) 400 when a GGUF chat
242
+ // template can't render the `tools` array. Drop native tools and retry once — the
243
+ // model drives tools via the JSON-in-prose protocol jeo always states in the prompt.
244
+ const errBody = await response.clone().text().catch(() => "");
245
+ if (isUnsupportedToolsError(body, errBody)) {
246
+ response = await fetch(url, { method: "POST", headers, body: stripNativeTools(body), signal: options.signal });
247
+ }
248
+ }
156
249
  if (!response.ok) throw await providerHttpError("OpenAI", response);
157
- const result = (await response.json()) as { choices: { message: { content?: string; tool_calls?: { function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
250
+
251
+ const result = (await response.json()) as { choices: { message: { content?: string; reasoning_content?: string; reasoning?: string; tool_calls?: { function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
158
252
  if (result.usage) options.onUsage?.({ inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens });
159
253
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
160
254
  const envelope = serializeToolCalls(parseOpenaiToolCalls(result.choices[0]?.message?.tool_calls));
161
255
  if (envelope) return envelope;
162
256
  const text = result.choices[0]?.message?.content ?? "";
163
- if (!text) throw emptyCompletionError(result.choices[0]?.finish_reason);
164
- return text;
257
+ if (text) return text;
258
+ // Recover from the reasoning channel before failing. LM Studio (and other local
259
+ // OpenAI-compatible servers) running reasoning models routinely emit the WHOLE reply —
260
+ // including the `{"tool":…}` JSON — into `reasoning_content`/`reasoning` while leaving
261
+ // `content` empty, which otherwise dies as a dead "returned no content" turn.
262
+ const finishReason = result.choices[0]?.finish_reason;
263
+ const msg = result.choices[0]?.message;
264
+ const recovered = recoverFromReasoning(msg?.reasoning_content ?? msg?.reasoning ?? "", finishReason);
265
+ if (recovered) return recovered;
266
+ throw emptyCompletionError(finishReason);
165
267
  },
166
268
  async *stream(messages, options, credential) {
167
269
  if (credential.kind === "oauth") {
@@ -191,15 +293,25 @@ export const openaiAdapter: ProviderAdapter = {
191
293
  const stripped = JSON.parse(body) as Record<string, unknown>;
192
294
  delete stripped.stream_options;
193
295
  response = await fetch(url, { method: "POST", headers, body: JSON.stringify(stripped), signal: options.signal });
296
+ } else if (isUnsupportedToolsError(body, errBody)) {
297
+ // A GGUF chat template that can't render the `tools` array (LM Studio jinja error).
298
+ // Drop native tools and retry once — the model drives tools via JSON-in-prose.
299
+ response = await fetch(url, { method: "POST", headers, body: stripNativeTools(body), signal: options.signal });
194
300
  }
195
301
  }
302
+
196
303
  if (!response.ok) throw await providerHttpError("OpenAI", response, "(stream)");
197
304
  if (!response.body) return;
198
305
  let yieldedAny = false;
199
306
  let finishReason: string | undefined;
307
+ // Mirror every reasoning delta into a local buffer so a content-less reply can be
308
+ // recovered from the reasoning channel at end-of-stream (see recoverFromReasoning).
309
+ let reasoningBuf = "";
310
+ const captureReasoning = (delta: string) => { reasoningBuf += delta; options.onReasoning?.(delta); };
200
311
  // Split inline <think>…</think> (DeepSeek-R1/Qwen-style local models) out of the
201
312
  // visible answer and onto the reasoning channel. No-op for models that never emit it.
202
- const think = createThinkSplitter(options.onReasoning);
313
+ const think = createThinkSplitter(captureReasoning);
314
+
203
315
  const toolAcc = new Map<number, { name: string; args: string }>();
204
316
  for await (const data of readSse(response.body, options.onStreamActivity)) {
205
317
  let chunk: { choices?: { delta?: OpenAIDelta; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
@@ -220,7 +332,7 @@ export const openaiAdapter: ProviderAdapter = {
220
332
  // <think> splitter): handles string fields, an object `reasoning`, and the
221
333
  // `reasoning_details[]` array form (OpenRouter/xAI/DeepSeek variants).
222
334
  const reason = reasoningDeltaOf(chunk.choices?.[0]?.delta);
223
- if (reason) options.onReasoning?.(reason);
335
+ if (reason) captureReasoning(reason);
224
336
  const tcs = chunk.choices?.[0]?.delta?.tool_calls;
225
337
  if (tcs) {
226
338
  for (const tc of tcs) {
@@ -239,7 +351,14 @@ export const openaiAdapter: ProviderAdapter = {
239
351
  // Native tool calls stream as tool_calls argument fragments — re-serialize once at end.
240
352
  const envelope = serializeAccumulatedToolCalls(toolAcc);
241
353
  if (envelope) { yieldedAny = true; yield envelope; }
242
- if (!yieldedAny) throw emptyCompletionError(finishReason);
354
+ if (!yieldedAny) {
355
+ // Recover from the reasoning channel before failing the turn. Local reasoning models
356
+ // (LM Studio, llama.cpp, …) often stream the whole reply — JSON tool call included —
357
+ // as reasoning/<think> with an empty `content`.
358
+ const recovered = recoverFromReasoning(reasoningBuf, finishReason);
359
+ if (recovered) { yield recovered; return; }
360
+ throw emptyCompletionError(finishReason);
361
+ }
243
362
  },
244
363
  };
245
364
 
package/src/ai/types.ts CHANGED
@@ -99,6 +99,9 @@ export interface CallOptions {
99
99
  jsonMode?: boolean;
100
100
  /** Per-call base URL override (OpenAI-compat / Ollama). */
101
101
  baseUrl?: string;
102
+ /** Ollama context window (`num_ctx`). Overrides the server's small default so jeo's
103
+ * large system prompt fits; ignored by non-Ollama providers. */
104
+ numCtx?: number;
102
105
  /** Optional sink for provider-reported token usage. */
103
106
  onUsage?: (usage: Usage) => void;
104
107
  /** Abort in-flight provider requests (Ctrl-C / timeout / supersede). */
@@ -27,16 +27,30 @@ export interface OAuthCallbackFlowOptions {
27
27
 
28
28
  export type CallbackResult = { code: string; state: string };
29
29
 
30
- const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>jeo — login complete</title>
30
+ const PAGE_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>jeo — login complete</title>
31
31
  <style>body{font-family:system-ui,sans-serif;background:#0d1117;color:#e6edf3;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
32
- .card{text-align:center;padding:2rem 3rem;border:1px solid #30363d;border-radius:12px;background:#161b22}
33
- h1{margin:0 0 .5rem;font-size:1.4rem}p{margin:0;color:#8b949e}</style></head>
34
- <body><div class="card"><h1>__TITLE__</h1><p>__MSG__</p></div></body></html>`;
32
+ .card{text-align:center;padding:2rem 3rem;border:1px solid #30363d;border-radius:12px;background:#161b22;max-width:30rem}
33
+ h1{margin:0 0 .5rem;font-size:1.4rem}p{margin:.25rem 0;color:#8b949e}.hint{margin-top:1rem;font-size:.85rem;color:#6e7681}</style></head>
34
+ <body><div class="card"><h1>__TITLE__</h1><p>__MSG__</p>__EXTRA__</div>__SCRIPT__</body></html>`;
35
+
36
+ // Seconds before the success tab attempts to auto-close. `window.close()` only
37
+ // succeeds on tabs the script itself opened; browsers may ignore it for
38
+ // OS-launched OAuth tabs, so the page also shows a clear "you can close" hint.
39
+ // ponytail: upgrade path — none needed; countdown is inline, no timer lib.
40
+ const AUTO_CLOSE_SECONDS = 3;
35
41
 
36
42
  function renderHtml(ok: boolean, msg: string): string {
37
- return SUCCESS_HTML
43
+ const extra = ok
44
+ ? `<p class="hint">This window will close in <span id="jeo-countdown">${AUTO_CLOSE_SECONDS}</span>s — you can also close it manually.</p>`
45
+ : "";
46
+ const script = ok
47
+ ? `<script>(function(){var n=${AUTO_CLOSE_SECONDS},el=document.getElementById("jeo-countdown");var t=setInterval(function(){n-=1;if(el)el.textContent=String(Math.max(n,0));if(n<=0){clearInterval(t);window.close();}},1000);})();</script>`
48
+ : "";
49
+ return PAGE_HTML
38
50
  .replace("__TITLE__", ok ? "Login complete \u2713" : "Login failed")
39
- .replace("__MSG__", msg);
51
+ .replace("__MSG__", msg)
52
+ .replace("__EXTRA__", extra)
53
+ .replace("__SCRIPT__", script);
40
54
  }
41
55
 
42
56
  export abstract class OAuthCallbackFlow {
@@ -121,7 +135,7 @@ export abstract class OAuthCallbackFlow {
121
135
  else if (expectedState && state !== expectedState) message = "State mismatch — possible CSRF attack";
122
136
  else {
123
137
  ok = true;
124
- message = "You can close this tab and return to the terminal.";
138
+ message = "Authentication succeeded. Return to the terminal — jeo is ready.";
125
139
  }
126
140
 
127
141
  const resolve = this.#resolve;
@@ -2,6 +2,7 @@ import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
3
  import { type LaunchFlags } from "./flags";
4
4
  import { tmuxCopyCommand } from "../../tui/clipboard";
5
+ import { jeoEnv } from "../../util/env";
5
6
 
6
7
  function hashString(input: string): string {
7
8
  let hash = 2166136261;
@@ -278,3 +279,137 @@ export function resolveWorktree(cwd: string, wt: string): string {
278
279
  }
279
280
  return abs;
280
281
  }
282
+ /**
283
+ * Ownership / idle metadata for one tmux session, parsed from `tmux list-sessions`.
284
+ * Backs {@link reapStaleTmuxSessions}, the sweep that closes the orphaned-session
285
+ * leak (detached, long-idle `jeo launch` REPLs each pin tens of MB and pile up
286
+ * across days — the "jeo/bun 프로세스가 쌓여 메모리가 점점 커진다" report).
287
+ */
288
+ export interface TmuxSessionInfo {
289
+ name: string;
290
+ /** A client is currently attached (someone is viewing it). */
291
+ attached: boolean;
292
+ /** Last-activity time, epoch SECONDS (tmux #{session_activity}). */
293
+ activitySec: number;
294
+ /** The @jeo-profile marker is set ⇒ jeo created/owns this session. */
295
+ jeoOwned: boolean;
296
+ }
297
+
298
+ /** Stable, tab-separated `tmux list-sessions -F` format the reaper parses. */
299
+ export const TMUX_REAP_LIST_FORMAT =
300
+ "#{session_name}\t#{session_attached}\t#{session_activity}\t#{@jeo-profile}";
301
+
302
+ /** Parse {@link TMUX_REAP_LIST_FORMAT} output into structured per-session info. */
303
+ export function parseTmuxSessionList(raw: string): TmuxSessionInfo[] {
304
+ const out: TmuxSessionInfo[] = [];
305
+ for (const line of raw.split("\n")) {
306
+ if (!line.trim()) continue;
307
+ const [name, attached, activity, jeo] = line.split("\t");
308
+ if (!name) continue;
309
+ const activitySec = Number(activity);
310
+ out.push({
311
+ name,
312
+ attached: attached === "1",
313
+ activitySec: Number.isFinite(activitySec) ? activitySec : 0,
314
+ jeoOwned: jeo === "1",
315
+ });
316
+ }
317
+ return out;
318
+ }
319
+
320
+ /**
321
+ * Pure selector: which jeo-owned tmux sessions are safe to reap. A session is
322
+ * stale when jeo owns it (the @jeo-profile marker), NO client is attached, and it
323
+ * has been idle longer than `idleMs`. Sessions in `keep` (e.g. the one we are about
324
+ * to attach) are never selected. The interactive REPL persists its transcript to
325
+ * `.jeo/sessions/` and is resumable with `--resume`, so reaping an abandoned,
326
+ * unattached, idle REPL discards only its in-memory live view.
327
+ */
328
+ export function selectReapableTmuxSessions(
329
+ sessions: TmuxSessionInfo[],
330
+ opts: { nowSec: number; idleMs: number; keep?: Iterable<string> },
331
+ ): string[] {
332
+ const keep = new Set(opts.keep ?? []);
333
+ const idleSec = opts.idleMs / 1000;
334
+ return sessions
335
+ .filter(s =>
336
+ s.jeoOwned &&
337
+ !s.attached &&
338
+ !keep.has(s.name) &&
339
+ opts.nowSec - s.activitySec >= idleSec,
340
+ )
341
+ .map(s => s.name);
342
+ }
343
+
344
+ /** Session reaping is on unless JEO_TMUX_REAP=0. */
345
+ export function tmuxReapEnabled(env: Record<string, string | undefined> = process.env): boolean {
346
+ return jeoEnv("TMUX_REAP", env) !== "0";
347
+ }
348
+
349
+ /** Idle threshold before an unattached jeo session is reaped (default 6h). 0 reaps every idle one; invalid → default. */
350
+ export function tmuxReapIdleMs(env: Record<string, string | undefined> = process.env): number {
351
+ const DEFAULT = 6 * 60 * 60 * 1000;
352
+ const raw = jeoEnv("TMUX_REAP_IDLE_MS", env);
353
+ if (raw == null) return DEFAULT;
354
+ const n = Number(raw);
355
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT;
356
+ }
357
+
358
+ export interface TmuxReapDeps {
359
+ /** Raw `tmux list-sessions` output (TMUX_REAP_LIST_FORMAT), or null on failure. */
360
+ list?: () => string | null;
361
+ /** Kill one session by name. */
362
+ kill?: (name: string) => void;
363
+ /** Epoch-ms clock (injectable for tests). */
364
+ now?: () => number;
365
+ env?: Record<string, string | undefined>;
366
+ }
367
+
368
+ /**
369
+ * Best-effort sweep of orphaned jeo tmux sessions. Each detached, long-idle
370
+ * `jeo launch` REPL holds tens of MB forever, so over days dozens accumulate and
371
+ * aggregate RSS climbs monotonically. This reaps the jeo-owned sessions with no
372
+ * client attached that have outlived the idle TTL — never the freshly created
373
+ * session(s) in `keep`. Returns the reaped session names. Opt out: JEO_TMUX_REAP=0.
374
+ */
375
+ export function reapStaleTmuxSessions(
376
+ tmuxBin: string,
377
+ keep: Iterable<string> = [],
378
+ deps: TmuxReapDeps = {},
379
+ ): string[] {
380
+ const env = deps.env ?? process.env;
381
+ if (!tmuxReapEnabled(env)) return [];
382
+ const now = deps.now ?? Date.now;
383
+ const list = deps.list ?? ((): string | null => {
384
+ try {
385
+ const res = Bun.spawnSync([tmuxBin, "list-sessions", "-F", TMUX_REAP_LIST_FORMAT], {
386
+ stdout: "pipe",
387
+ stderr: "ignore",
388
+ });
389
+ return res.exitCode === 0 ? res.stdout.toString() : null;
390
+ } catch {
391
+ return null;
392
+ }
393
+ });
394
+ const raw = list();
395
+ if (!raw) return [];
396
+ const reapable = selectReapableTmuxSessions(parseTmuxSessionList(raw), {
397
+ nowSec: Math.floor(now() / 1000),
398
+ idleMs: tmuxReapIdleMs(env),
399
+ keep,
400
+ });
401
+ if (reapable.length === 0) return [];
402
+ const kill = deps.kill ?? ((name: string): void => {
403
+ try {
404
+ Bun.spawnSync([tmuxBin, "kill-session", "-t", `=${name}`], { stdout: "ignore", stderr: "ignore" });
405
+ } catch {
406
+ /* best-effort: the session may have died on its own between list and kill */
407
+ }
408
+ });
409
+ const reaped: string[] = [];
410
+ for (const name of reapable) {
411
+ kill(name);
412
+ reaped.push(name);
413
+ }
414
+ return reaped;
415
+ }
@@ -123,6 +123,7 @@ import {
123
123
  currentTmuxClipboardCommands,
124
124
  resolveWorktree,
125
125
  shellQuote,
126
+ reapStaleTmuxSessions,
126
127
  type TmuxCreateResult,
127
128
  type TmuxProfileCommand,
128
129
  } from "./launch/tmux";
@@ -210,6 +211,7 @@ export {
210
211
  currentTmuxClipboardCommands,
211
212
  resolveWorktree,
212
213
  shellQuote,
214
+ reapStaleTmuxSessions,
213
215
  type TmuxCreateResult,
214
216
  type TmuxProfileCommand,
215
217
  type InFlightAbortHarness,
@@ -402,6 +404,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
402
404
  for (const profileCmd of tmuxProfileCommands(sessionName, process.env, { branch: branch || undefined, project: cwd })) {
403
405
  try { Bun.spawnSync([tmuxBin, ...profileCmd.args]); } catch { /* best-effort */ }
404
406
  }
407
+ // Reap orphaned jeo sessions: each detached, long-idle `jeo launch` REPL
408
+ // pins tens of MB forever, so over days they pile up and aggregate RSS
409
+ // climbs (the "jeo/bun 프로세스가 쌓여 메모리가 점점 커진다" report). Sweep the
410
+ // jeo-owned, unattached, long-idle ones — never the one just created.
411
+ // Best-effort; opt out with JEO_TMUX_REAP=0.
412
+ try {
413
+ const reaped = reapStaleTmuxSessions(tmuxBin, [sessionName]);
414
+ if (reaped.length > 0) {
415
+ console.log(
416
+ `Reaped ${reaped.length} idle jeo tmux session${reaped.length === 1 ? "" : "s"}: ${reaped.join(", ")}`,
417
+ );
418
+ }
419
+ } catch { /* best-effort */ }
405
420
  console.log(
406
421
  sessionName === sessionBase
407
422
  ? `Starting new tmux session: ${sessionName}`
@@ -2650,7 +2665,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2650
2665
  setImmediate(() => {
2651
2666
  void (async () => {
2652
2667
  try {
2653
- const rli = rl as unknown as { line: string; cursor: number };
2668
+ const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2654
2669
  const before = rli.line;
2655
2670
  const dropped = await attachImagePaths(before, pendingImages.length + 1);
2656
2671
  if (dropped.images.length === 0 || dropped.text === before) return;
@@ -2658,6 +2673,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2658
2673
  rli.line = dropped.text;
2659
2674
  rli.cursor = dropped.text.length; // drop lands at the caret (line end in the common case)
2660
2675
  typedLine = dropped.text;
2676
+ // Keep readline's internal screen model in step with the swapped-in tag so
2677
+ // the caret is not offset on the next keystroke (mirrors the Ctrl+V path).
2678
+ rli._refreshLine?.();
2661
2679
  if (previewArmed) drawFooter(previewLines(typedLine, navIdx));
2662
2680
  } finally {
2663
2681
  pasteInFlight = false;
@@ -2719,12 +2737,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2719
2737
  if (!img) return;
2720
2738
  pendingImages.push(img);
2721
2739
  const tag = `[image #${pendingImages.length}]`;
2722
- const rli = rl as unknown as { line: string; cursor: number };
2740
+ const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2723
2741
  const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
2724
2742
  const sep = rli.line.length > 0 && at > 0 && rli.line[at - 1] !== " " ? " " : "";
2725
2743
  rli.line = rli.line.slice(0, at) + sep + tag + " " + rli.line.slice(at);
2726
2744
  rli.cursor = at + sep.length + tag.length + 1;
2727
2745
  typedLine = rli.line;
2746
+ // Sync readline's internal screen model to the injected tag (same as the
2747
+ // slash/tab completion accept paths): without it readline's stale row/cursor
2748
+ // model offsets the real caret several columns on the next keystroke — the
2749
+ // "caret pushed past the tag after attaching an image" shift.
2750
+ rli._refreshLine?.();
2728
2751
  if (previewArmed) drawFooter(previewLines(typedLine, navIdx));
2729
2752
  } finally {
2730
2753
  pasteInFlight = false;
@@ -346,7 +346,8 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
346
346
  const currentTask = teamState.pending_tasks[0];
347
347
  log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
348
348
  const activeIndex = activeStepIndex(tasks.length, teamState.pending_tasks);
349
- for (const line of formatRalphTodoGuide(tasks, activeIndex, teamState.completed_tasks ?? [], renderOpts)) log(line);
349
+ // Guide printed once above; per-task lines convey progress. No per-iteration
350
+ // reprint — it was O(N²) lines, climbing the embedded-terminal renderer's CPU.
350
351
 
351
352
  if (opts.onProgress) {
352
353
  opts.onProgress({ skill: "team", phase: "executing", detail: `Current task: ${currentTask}` });
package/src/tui/app.ts CHANGED
@@ -126,6 +126,15 @@ export function tailForWrap(text: string, maxChars = FRAME_WRAP_TAIL_CHARS): str
126
126
  return text.length > maxChars ? text.slice(text.length - maxChars) : text;
127
127
  }
128
128
 
129
+ /** Memoization key for a live-block wrap (Thinking / tool Output). Derived from the
130
+ * TRAILING window only (see `tailForWrap`): the rendered rows depend solely on the
131
+ * tail, so keying on the full streamed buffer just made every 120ms tick and every
132
+ * delta copy + compare the whole growing string — O(len) per frame, i.e. O(len²) over
133
+ * a long stream. Bounding the key to the ≤16KB tail keeps per-frame cost O(window). */
134
+ export function liveBlockWrapKey(wrapW: number, text: string): string {
135
+ return `${wrapW}\u0000${tailForWrap(text)}`;
136
+ }
137
+
129
138
  /** Max lines of a committed reasoning block kept in scrollback (gjc-style collapse): a
130
139
  * long chain-of-thought is clipped with a "+N more" hint so it never floods the ledger. */
131
140
  export const THINKING_COMMIT_MAX_LINES = 12;
@@ -1300,14 +1309,18 @@ export class LaunchTui {
1300
1309
  const dim = this.theme.color ? chalk.dim : (s: string) => s;
1301
1310
  if (!text.trim()) return [];
1302
1311
  const wrapW = Math.max(8, cols - 2);
1303
- // Memoize the wrap: only the spinner/clock change on most 120ms ticks, so re-wrapping
1304
- // this (up to 16KB) tail every frame just re-segments graphemes for no visible change.
1305
- // Per-label slot (Thinking / Output) keyed by wrap width + text — a real delta misses
1306
- // once and recomputes; an idle tick hits the cache. `rows` only gates the post-slice.
1312
+ // Memoize the wrap on the TRAILING window only. The block displays just its last few
1313
+ // wrapped rows, so wrap cost is already capped by `tailForWrap`. The cache KEY must be
1314
+ // bounded too: keying on the full (up-to-hundreds-of-KB) `text` made every 120ms tick
1315
+ // and every stream delta copy + compare the whole growing buffer — O(len) per frame, so
1316
+ // O(len²) over a long reasoning/tool stream (the streaming slowdown). Slice first, then
1317
+ // key + wrap the ≤16KB tail: identical visible output, O(tail) per frame regardless of
1318
+ // total size. Per-label slot (Thinking / Output) keyed by wrap width + tail.
1307
1319
  let cache = this.liveBlockWrapCaches.get(cacheKey);
1308
1320
  if (!cache) { cache = lastValueCache<string[]>(); this.liveBlockWrapCaches.set(cacheKey, cache); }
1309
- const wrapped = cache(`${wrapW}\u0000${text}`, () =>
1310
- tailForWrap(text)
1321
+ const tail = tailForWrap(text);
1322
+ const wrapped = cache(liveBlockWrapKey(wrapW, text), () =>
1323
+ tail
1311
1324
  .split("\n")
1312
1325
  .flatMap(l => wrapTextWithAnsi(l, wrapW))
1313
1326
  .filter(l => l.length > 0));