jeo-code 0.7.8 → 0.7.10
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 +17 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +2 -0
- package/src/agent/state.ts +2 -0
- package/src/agent/tool-output.ts +1 -1
- package/src/agent/tools.ts +3 -2
- package/src/ai/model-manager.ts +21 -5
- package/src/ai/provider-status.ts +9 -3
- package/src/ai/providers/ollama.ts +21 -1
- package/src/ai/providers/openai.ts +129 -10
- package/src/ai/types.ts +3 -0
- package/src/auth/callback-server.ts +21 -7
- package/src/commands/launch/tmux.ts +135 -0
- package/src/commands/launch.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ 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
|
+
|
|
9
26
|
## [0.7.8] - 2026-06-24
|
|
10
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._
|
|
11
28
|
|
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.
|
|
196
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.
|
|
197
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.
|
|
198
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.
|
|
199
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.
|
|
200
|
-
- **[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.
|
|
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.
|
|
196
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.
|
|
197
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.
|
|
198
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.
|
|
199
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.
|
|
200
|
-
- **[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.
|
|
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.
|
|
198
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.
|
|
199
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.
|
|
200
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.
|
|
201
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.
|
|
202
|
-
- **[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.
|
|
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.
|
|
196
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.
|
|
197
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.
|
|
198
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.
|
|
199
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.
|
|
200
|
-
- **[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.
|
|
201
201
|
|
|
202
202
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
203
203
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -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(),
|
package/src/agent/state.ts
CHANGED
|
@@ -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). */
|
package/src/agent/tool-output.ts
CHANGED
|
@@ -83,7 +83,7 @@ export async function spillToolResult(tool: string, output: string, cwd: string)
|
|
|
83
83
|
await fs.mkdir(dir, { recursive: true });
|
|
84
84
|
const safeTool = tool.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "tool";
|
|
85
85
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
86
|
-
const rel =
|
|
86
|
+
const rel = `.jeo/artifacts/tool-results/${stamp}-${safeTool}.txt`;
|
|
87
87
|
await fs.writeFile(path.join(cwd, rel), output, "utf-8");
|
|
88
88
|
// Retention so a long session can't grow the artifact dir without bound.
|
|
89
89
|
await pruneToolArtifacts(dir);
|
package/src/agent/tools.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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),
|
|
@@ -518,7 +533,8 @@ export function streamIdleMs(env?: Record<string, string | undefined>): number {
|
|
|
518
533
|
* built-in default. Mirrors streamIdleMs so a slow non-interactive turn (callLlm without
|
|
519
534
|
* onToken) can relax the cap via JEO_CALL_TIMEOUT_MS without a code change. */
|
|
520
535
|
export function callTimeoutMs(env?: Record<string, string | undefined>): number {
|
|
521
|
-
const
|
|
536
|
+
const e = env ?? process.env;
|
|
537
|
+
const raw = jeoEnv("CALL_TIMEOUT_MS", e) ?? e["ANTHROPIC_TIMEOUT"];
|
|
522
538
|
const n = raw !== undefined ? parseInt(raw, 10) : NaN;
|
|
523
539
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CALL_TIMEOUT_MS;
|
|
524
540
|
}
|
|
@@ -81,11 +81,17 @@ function configuredCredential(provider: AuthProvider, cfg: Config): Credential {
|
|
|
81
81
|
return { kind: "none", provider };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
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
|
|
88
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
164
|
-
|
|
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(
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
34
|
-
<body><div class="card"><h1>__TITLE__</h1><p>__MSG__</p
|
|
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
|
-
|
|
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 = "
|
|
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
|
+
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -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}`
|