jeo-code 0.10.0 → 0.11.0

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
@@ -9,6 +9,28 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.11.0] - 2026-08-25
13
+ _One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside._
14
+
15
+ ### Fixed
16
+ - **The project-context parent walk did not stop at the home directory when `$HOME` was unset** (`src/agent/context-files.ts`) — the boundary was `process.env.HOME ? path.resolve(...) : null`, and a `null` guard never fires. `$HOME` is genuinely absent in bare Docker images, systemd units without `User=`, `env -i` wrappers and several CI runners, so on those hosts the walk climbed *past* the user's home into `/Users`, `/home` or `/` and spliced whatever `AGENTS.md` / `CLAUDE.md` lived there straight into the system prompt — on a shared machine, potentially another account's. Now resolved through `os.homedir()` (the OS user database, which answers correctly without the env var), the boundary test is "at or **above** home" so it also holds when the working directory is outside the home subtree entirely, and a `MAX_PARENT_WALK_DEPTH` cap makes walking the whole filesystem structurally impossible.
17
+ - **The user-global guidance scan read the current working directory when `$HOME` was unset** (`src/agent/context-files.ts`) — roots were built with `path.join(process.env.HOME || "", ".agents", "rules")`, and `path.join("", …)` yields a **relative** path, so the "user global" scan silently read `./.agents/rules` and presented a project's own files as the user's global config. No resolvable home now means no home roots, full stop.
18
+ - **Subagent fan-out burned an extra dispatch per refusal** (`src/agent/task-tool.ts` path, via the same context bug) — the leaked ancestor context made the refusal ladder's rung 3 (guidance-strip) fire as a real retry instead of the intended no-op, so every uncategorized refusal cost 4 model calls where 3 were budgeted, against `MAX_SUBAGENT_REROUTES`.
19
+ - **The Telegram daemon's PID-reuse guard silently degraded to a bare `kill(pid, 0)`** (`src/agent/notify/daemon-control.ts`) — `processStartTimeMs` had a single point of failure in `ps`, which is exactly what is missing where daemons actually run: distroless/scratch containers ship no `ps`, and hardened sandboxes block the setuid `/bin/ps`. With the start-time check unavailable, `jeo daemon stop` would happily `SIGTERM` a recycled PID belonging to an unrelated process. Added a `/proc/<pid>/stat` + `/proc/uptime` fast path (Linux, no subprocess, works in distroless) ahead of `ps`, and split the result into `{ alive, verified }` via the new `inspectLockOwner` so the two facts stop being conflated.
20
+
21
+ ### Added
22
+ - **`config.subagentConcurrency`** (`resolveFanoutLimit`, default 4, clamped 1..16) — fan-out concurrency is now tunable per machine instead of a hardcoded `MAX_FANOUT`. Four concurrent Anthropic streams trip a Tier-1 rate limit immediately, turning a fan-out into a backoff storm that is *slower* than running sequentially; accounts with headroom can raise it. Bounds both the `task` tool's `tasks[]` batch and the `eval` tool's `parallel`/`pipeline` helpers.
23
+ - **Retired-model registry** (`src/ai/model-retirements.ts`) + a `jeo doctor` check that names the exact stale config key. Alias expansion already reroutes a sunset id to its successor, which means a stale pin keeps working and the user is *never told* their `defaultModel` / `roles.*` / `subagents.<role>.model` is out of date — until the alias is eventually dropped and every turn starts 404-ing. `findRetiredPins` reports the location, the replacement and why, and the table is constrained to same-provider successors so nobody is silently moved to another vendor.
24
+ - **`daemonStatus().ownerVerified`**, surfaced by `jeo daemon status` — says outright when the host cannot report process start times, so "running" is not mistaken for "verified".
25
+
26
+ ### Changed
27
+ - README (en/ko/ja/zh): custom-provider registry in the highlights, the `/provider add|list|remove|presets` row in the slash table, and new Configuration sections for `customProviders` and `subagentConcurrency`.
28
+
29
+ ### Verified
30
+ - `bun run typecheck` clean; `bun test` **3339 pass**, failures down from **22 to 6** against the 0.10.0 baseline. The 16 fixed are real (13 context/fan-out, 3 daemon); the remaining 6 are sandbox-only (`bashTool` child signals, REPL stdin-EOF and `--resume` spawn timeouts) and unrelated to this change.
31
+ - 3 PID-reuse tests are now capability-gated (`canVerifyProcessStartTime`) rather than failing on hosts without `ps`//proc, with 2 new tests pinning the **degraded** contract so the fallback path stays covered everywhere.
32
+ - 8 new tests in `test/model-retirements-fanout.test.ts`; `test/context-files.test.ts` isolation corrected to an empty temp `HOME` (deleting `$HOME` no longer isolates, by design).
33
+
12
34
  ## [0.10.0] - 2026-08-25
13
35
  _jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script._
14
36
 
package/README.ja.md CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  ## ハイライト
40
40
 
41
- - **マルチプロバイダ・単一ループ** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio に加え、OpenAI・Anthropic 互換クラウド20以上(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai など)を均一な JSON ツールループで。入力欄から OAuth ログイン(`/provider login`)、モデル選択は即座にデフォルトとして永続化。プロンプトルーティングは実際に使える認証済み経路だけを自動選択します: Gemini OAuth は provider-qualified な `antigravity/*` エージェントセット(Gemini 3.5 Flash 各ティア、Gemini 3.1 Pro、Claude Sonnet/Opus 4.6)へ向かい、`GEMINI_API_KEY` が必要な public `google/gemini-*` 行は選びません。
41
+ - **マルチプロバイダ・単一ループ** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio に加え、OpenAI・Anthropic 互換クラウド20以上(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai など)を均一な JSON ツールループで — **さらに自分で登録したエンドポイントも**: `jeo provider add --id my-proxy --base-url https://…`(または `--preset litellm|vllm|sglang|azure-openai|…`)で、LiteLLM プロキシ・自前ホストの vLLM・社内 Anthropic ゲートウェイが、組み込みの `openai` プロバイダを乗っ取ることなく独自のルーティング接頭辞・モデル一覧・認証情報を持てます。入力欄から OAuth ログイン(`/provider login`)、モデル選択は即座にデフォルトとして永続化。プロンプトルーティングは実際に使える認証済み経路だけを自動選択します: Gemini OAuth は provider-qualified な `antigravity/*` エージェントセット(Gemini 3.5 Flash 各ティア、Gemini 3.1 Pro、Claude Sonnet/Opus 4.6)へ向かい、`GEMINI_API_KEY` が必要な public `google/gemini-*` 行は選びません。
42
42
  - **編集の完全性** — read 出力にコンテンツアンカー(`42ab|`)が付き、アンカー付き編集は現在のファイルと照合・行移動時は自動再マッピング・不一致時は最新内容と共に拒否されます。
43
43
  - **自己修正の検証ループ** — post-edit フック(tsc / eslint / テスト)を設定すると、エージェントが診断を*自ら読み*ループ内で修正します。フックが赤のままだと `done` はブロックされます。
44
44
  - **芝居なしの本物のゲート** — `ralplan` の合議はリポジトリを実際に読む critic サブエージェントで、`[OKAY]` 評決が永続化され `jeo approve` がそれを*要求*します。`ultragoal` は誠実に報告します(スイート1回実行はグローバル信号であり、基準ごとの合格を捏造しません)。
@@ -87,6 +87,7 @@ jeo --tmux # 独立した tmux セッションで実行
87
87
  | --- | --- |
88
88
  | `/model` · `/provider` | モデル/プロバイダ選択; `/model` でデフォルト/ロールバッジ、Ralph 風の入れ子ロール・thinking 選択、OpenAI Codex ロールプリセットを一つの流れで設定 |
89
89
  | `/provider login <name>` · `/logout` | 入力欄から OAuth ログイン/ログアウト |
90
+ | `/provider add` · `list` · `remove` · `presets` | OpenAI/Anthropic 互換エンドポイントを正式なプロバイダとして登録(ゲートウェイプリセット15種) |
90
91
  | `/agents [role]` · `/subagent` | ロール別(executor/planner/architect/critic)モデル・thinking・ステップ設定 |
91
92
  | `/thinking [level]` | デフォルト推論予算(low…xhigh)の表示/設定 |
92
93
  | `/route [status\|on\|off\|why\|history [n]]` | セッション単位のプロンプトベース・モデルルーティングの切替 · 直近のルーティング判断の説明 · `history [n]` はこのセッションの直近 n 件(デフォルト 10 件)のルーティング判断を表示(設定済み資格情報 — OAuth または API キー — が実際に提供するモデルの中でのみ自動ルーティング) |
@@ -246,6 +247,37 @@ JEO_TOOL_OUTPUT_MAX=4000 # モデル可視のツール出力上限(全文
246
247
 
247
248
  リトライ動作は `~/.jeo/config.json` の `retry` で調整します(`requestMaxRetries`、`streamMaxRetries`、`rateLimitRetries`、`failFastStatuses` など)。ステップ予算はデフォルトで動的 — 新規進捗が見える間は延長され、停滞時は要約に収束します。`--max-steps N` で有限フローに戻ります。
248
249
 
250
+ ### カスタムプロバイダ
251
+
252
+ ```jsonc
253
+ {
254
+ "customProviders": {
255
+ "my-proxy": {
256
+ "baseUrl": "https://gateway.internal/v1",
257
+ "protocol": "openai", // または "anthropic" (/v1/messages ワイヤ形式)
258
+ "apiKeyEnv": "MY_PROXY_API_KEY", // 既定: <ID>_API_KEY
259
+ "models": ["fast", "smart"] // オフライン一覧。ライブ /models が優先されます
260
+ }
261
+ }
262
+ }
263
+ ```
264
+
265
+ シェル(`jeo provider add|list|remove|presets|test`)からも入力欄(`/provider add …`)からも
266
+ 同じレジストリを扱えます。`jeo provider test <id>` はエンドポイントを実際に叩き、
267
+ **認証情報の問題とエンドポイントの問題を分けて**報告し、失敗時は非ゼロ終了するため
268
+ プロビジョニングスクリプトのゲートに使えます。
269
+
270
+ ### サブエージェントのファンアウト並列度
271
+
272
+ ```jsonc
273
+ { "subagentConcurrency": 2 } // 既定 4、1〜16 にクランプ
274
+ ```
275
+
276
+ Anthropic の Tier-1 アカウントでは同時ストリーム4本で即座にレートリミットに達し、
277
+ ファンアウトが逐次実行より*遅い*バックオフの嵐になります。制限の厳しいアカウントでは
278
+ 下げ、余裕があれば上げてください。`task` ツールの `tasks[]` バッチと `eval` ツールの
279
+ `parallel`/`pipeline` ヘルパーの両方に効きます。
280
+
249
281
  ## スキル移行とバンドルスキルの確認
250
282
 
251
283
  ワークフローを jeo に移す際は、何かをインストール・上書きする前にバンドルされたデフォルトを確認してください:
@@ -287,10 +319,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
287
319
 
288
320
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
289
321
  - **[Unreleased]**
322
+ - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
290
323
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
291
324
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
292
325
  - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
293
- - **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
294
326
 
295
327
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
296
328
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  ## 하이라이트
40
40
 
41
- - **멀티 프로바이더, 단일 루프** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio, 그리고 OpenAI·Anthropic 호환 클라우드 20종 이상(Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai 등)까지 균일한 JSON 도구 루프로. 입력창에서 바로 OAuth 로그인(`/provider login`), 모델 선택은 즉시 기본값으로 영속됩니다. 프롬프트 라우팅은 실제 사용 가능한 인증 경로만 자동 선택합니다: Gemini OAuth는 provider-qualified `antigravity/*` 에이전트 세트(Gemini 3.5 Flash 등급, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6)로만 가고, `GEMINI_API_KEY`가 필요한 public `google/gemini-*` 행은 고르지 않습니다.
41
+ - **멀티 프로바이더, 단일 루프** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio, 그리고 OpenAI·Anthropic 호환 클라우드 20종 이상(Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai 등)까지 균일한 JSON 도구 루프로 — **여기에 직접 등록한 엔드포인트까지**: `jeo provider add --id my-proxy --base-url https://…` (또는 `--preset litellm|vllm|sglang|azure-openai|…`)로 LiteLLM 프록시·자체 호스팅 vLLM·사내 Anthropic 게이트웨이가 내장 `openai` 프로바이더를 덮어쓰지 않고 자기만의 라우팅 접두사·모델 목록·자격증명을 갖습니다. 입력창에서 바로 OAuth 로그인(`/provider login`), 모델 선택은 즉시 기본값으로 영속됩니다. 프롬프트 라우팅은 실제 사용 가능한 인증 경로만 자동 선택합니다: Gemini OAuth는 provider-qualified `antigravity/*` 에이전트 세트(Gemini 3.5 Flash 등급, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6)로만 가고, `GEMINI_API_KEY`가 필요한 public `google/gemini-*` 행은 고르지 않습니다.
42
42
  - **편집 무결성** — read 출력에 콘텐츠 앵커(`42ab|`)가 붙고, 앵커 편집은 현재 파일과 대조 검증·줄 이동 시 자동 재매핑·불일치 시 최신 내용과 함께 거부 — 파일을 오염시키지 않습니다.
43
43
  - **자기수정 검증 루프** — post-edit 훅(tsc / eslint / 테스트)을 설정하면 에이전트가 진단을 *직접 읽고* 루프 안에서 수정합니다. 훅이 빨간 상태면 `done`이 차단됩니다.
44
44
  - **연극 없는 진짜 게이트** — `ralplan` 합의는 실제 저장소를 읽는 critic 서브에이전트이며 `[OKAY]` 평결이 영속되고 `jeo approve`가 이를 *요구*합니다. `ultragoal`은 정직하게 보고합니다(스위트 1회 실행은 전역 신호일 뿐, 기준별 통과를 조작하지 않음).
@@ -88,6 +88,7 @@ jeo --tmux # 독립 tmux 세션에서 실행
88
88
  | --- | --- |
89
89
  | `/model` · `/provider` | 모델/프로바이더 선택; `/model`에서 기본/역할 배지, Ralph식 하위 리스트 역할·thinking 선택, OpenAI Codex 역할 프리셋을 한 흐름으로 설정 |
90
90
  | `/provider login <name>` · `/logout` | 입력창에서 OAuth 로그인/로그아웃 |
91
+ | `/provider add` · `list` · `remove` · `presets` | OpenAI/Anthropic 호환 엔드포인트를 정식 프로바이더로 등록 (게이트웨이 프리셋 15종) |
91
92
  | `/agents [role]` · `/subagent` | 역할별(executor/planner/architect/critic) 모델·thinking·스텝 구성 |
92
93
  | `/thinking [level]` | 기본 추론 예산(low…xhigh) 조회/설정 |
93
94
  | `/route [status\|on\|off\|why\|history [n]]` | 세션별 프롬프트 기반 모델 라우팅 켜기/끄기 · 마지막 라우팅 결정 설명 · `history [n]`은 이번 세션의 최근 n개(기본 10개) 라우팅 결정 표시(설정된 자격증명 — OAuth 또는 API 키 — 이 실제로 서비스하는 모델 안에서만 자동 라우팅) |
@@ -247,6 +248,37 @@ JEO_TOOL_OUTPUT_MAX=4000 # 모델 가시 도구 출력 캡(전체는 아
247
248
 
248
249
  재시도 동작은 `~/.jeo/config.json`의 `retry`로 조정합니다(`requestMaxRetries`, `streamMaxRetries`, `rateLimitRetries`, `failFastStatuses`, …). 스텝 버짓은 기본 동적 — 새로운 진전이 보이는 동안 연장되고 정체 시 요약으로 수렴하며, `--max-steps N`이면 유한 플로로 복귀합니다.
249
250
 
251
+ ### 커스텀 프로바이더
252
+
253
+ ```jsonc
254
+ {
255
+ "customProviders": {
256
+ "my-proxy": {
257
+ "baseUrl": "https://gateway.internal/v1",
258
+ "protocol": "openai", // 또는 "anthropic" (/v1/messages 와이어 포맷)
259
+ "apiKeyEnv": "MY_PROXY_API_KEY", // 기본값: <ID>_API_KEY
260
+ "models": ["fast", "smart"] // 오프라인 목록. 라이브 /models 가 우선합니다
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ 셸(`jeo provider add|list|remove|presets|test`)에서도, 입력창(`/provider add …`)에서도
267
+ 같은 레지스트리를 다룹니다. `jeo provider test <id>`는 엔드포인트를 실제로 찔러보고
268
+ **자격증명 문제와 엔드포인트 문제를 따로** 보고하며, 실패 시 non-zero로 종료하므로
269
+ 프로비저닝 스크립트에서 게이트로 쓸 수 있습니다.
270
+
271
+ ### 서브에이전트 팬아웃 동시성
272
+
273
+ ```jsonc
274
+ { "subagentConcurrency": 2 } // 기본 4, 1~16으로 클램프
275
+ ```
276
+
277
+ Anthropic Tier-1 계정은 동시 스트림 4개에서 곧바로 레이트 리밋에 걸려, 팬아웃이
278
+ 순차 실행보다 *느려지는* 백오프 폭풍이 됩니다. 레이트 리밋이 빡빡한 계정은 낮추고,
279
+ 여유가 있으면 올리세요. `task` 도구의 `tasks[]` 배치와 `eval` 도구의
280
+ `parallel`/`pipeline` 헬퍼 양쪽 모두에 적용됩니다.
281
+
250
282
  ## 스킬 마이그레이션 및 번들 스킬 확인
251
283
 
252
284
  워크플로를 jeo로 옮기기 전에, 무언가를 설치하거나 덮어쓰기 전에 먼저 번들 기본값을 확인하세요:
@@ -288,10 +320,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
288
320
 
289
321
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
290
322
  - **[Unreleased]**
323
+ - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
291
324
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
292
325
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
293
326
  - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
294
- - **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
295
327
 
296
328
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
297
329
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -42,7 +42,7 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
42
42
 
43
43
  - **Prompt routing (cost-aware, credential-aware)** — every turn can auto-route to a tier-appropriate model among only the providers your configured credentials actually serve (`/route [status|on|off|why|history]`), with a live equivalent-model fallback whenever a routed provider is rate-limited, unauthenticated, unreachable, or silently times out — see `/route why` for the last decision, `/route history` for the recent ones.
44
44
  - **Computer use (desktop automation)** — a fail-closed `computer` tool (screenshot/click/type/scroll/drag/batch) gated by both a config flag and an independent kill-switch/heartbeat supervisor; toggle it for the current session with `/computer [status|on|off]` without touching `~/.jeo/config.json`.
45
- - **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY`; if a configured route points at an unready provider, jeo switches to an equivalent credentialed tier model before falling back to the default.
45
+ - **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop — **plus any endpoint you register yourself**: `jeo provider add --id my-proxy --base-url https://…` (or `--preset litellm|vllm|sglang|azure-openai|…`) gives a LiteLLM proxy, a self-hosted vLLM box or a corporate Anthropic gateway its own routing prefix, model list and credential, instead of hijacking the built-in `openai` provider. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY`; if a configured route points at an unready provider, jeo switches to an equivalent credentialed tier model before falling back to the default.
46
46
 
47
47
  - **Edit integrity** — read output carries content anchors (`42ab|`); anchored edits are verified against the current file, re-mapped when lines shifted, and rejected with fresh content instead of corrupting.
48
48
  - **Self-correcting verification loop** — configure a post-edit hook (tsc / eslint / tests) and the agent *sees* the diagnostics and fixes them in-loop; a red hook blocks `done` until resolved.
@@ -93,6 +93,7 @@ Inside the `jeo` REPL (Tab autocompletes; `/` opens the palette).
93
93
  | --- | --- |
94
94
  | `/model` · `/provider` | Pick model/provider; `/model` shows default/role badges, Ralph-style nested Set-as-role thinking choices, and the OpenAI Codex role preset in one flow |
95
95
  | `/provider login <name>` · `/logout` | OAuth login/logout from the input box |
96
+ | `/provider add` · `list` · `remove` · `presets` | Register OpenAI/Anthropic-compatible endpoints as first-class providers (15 gateway presets) |
96
97
  | `/agents [role]` · `/subagent` | Per-role (executor/planner/architect/critic) model · thinking · step config |
97
98
  | `/thinking [level]` | Show/set default reasoning budget (low…xhigh) |
98
99
  | `/route [status\|on\|off\|why\|history [n]]` | Toggle prompt-based model routing for this session · explain the last routing decision · `history [n]` lists the last n (default 10) routing decisions this session (auto-routes each turn to a tier-appropriate model among the models your configured credentials — OAuth or API key — actually serve, and switches to an equivalent tier model when a configured route is unready) |
@@ -195,7 +196,7 @@ jeo notify setup # pair a BotFather bot once (getMe verification + chat-i
195
196
  jeo notify status # masked token, paired chat id, daemon state
196
197
  jeo daemon start # spawn the singleton background daemon
197
198
  jeo daemon status # check whether it's running
198
- jeo daemon stop # SIGTERM it
199
+ jeo daemon stop # SIGTERM it (refuses to signal a recycled pid)
199
200
  ```
200
201
 
201
202
  ```
@@ -253,6 +254,37 @@ JEO_TOOL_OUTPUT_MAX=4000 # model-visible tool output cap (full output spi
253
254
 
254
255
  Retry behavior is tunable via `retry` in `~/.jeo/config.json` (`requestMaxRetries`, `streamMaxRetries`, `rateLimitRetries`, `failFastStatuses`, …). The step budget is dynamic by default — it extends while recent tool calls show novel progress and consolidates with a wrap-up when stalled; `--max-steps N` restores a bounded flow.
255
256
 
257
+ ### Custom providers
258
+
259
+ ```jsonc
260
+ {
261
+ "customProviders": {
262
+ "my-proxy": {
263
+ "baseUrl": "https://gateway.internal/v1",
264
+ "protocol": "openai", // or "anthropic" (/v1/messages wire format)
265
+ "apiKeyEnv": "MY_PROXY_API_KEY", // default: <ID>_API_KEY
266
+ "models": ["fast", "smart"] // offline pick-list; live /models supersedes it
267
+ }
268
+ }
269
+ }
270
+ ```
271
+
272
+ Manage it from the shell (`jeo provider add|list|remove|presets|test`) or the input box
273
+ (`/provider add …`). `jeo provider test <id>` probes the endpoint and reports the
274
+ credential source and endpoint health *separately*, exiting non-zero on failure so
275
+ provisioning scripts can gate on it.
276
+
277
+ ### Subagent fan-out concurrency
278
+
279
+ ```jsonc
280
+ { "subagentConcurrency": 2 } // default 4, clamped to 1..16
281
+ ```
282
+
283
+ Four concurrent Anthropic streams trip a Tier-1 rate limit immediately, turning a
284
+ fan-out into a backoff storm that is *slower* than running sequentially. Lower this on a
285
+ rate-limited account; raise it when the provider has headroom. It bounds both the `task`
286
+ tool's `tasks[]` batch and the `eval` tool's `parallel`/`pipeline` helpers.
287
+
256
288
  ## Skill migration and bundled skill inspection
257
289
 
258
290
  When moving a workflow into jeo, inspect the bundled defaults before installing or overwriting anything:
@@ -295,10 +327,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
295
327
 
296
328
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
297
329
  - **[Unreleased]**
330
+ - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
298
331
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
299
332
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
300
333
  - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
301
- - **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
302
334
 
303
335
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
304
336
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  ## 亮点
40
40
 
41
- - **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio,以及 20+ 个 OpenAI、Anthropic 兼容云服务(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai 等),统一在一个 JSON 工具循环中。输入框内直接 OAuth 登录(`/provider login`),模型选择即刻持久化为默认值。Prompt routing 只会自动选择真实可用的凭据路径: Gemini OAuth 会走 provider-qualified 的 `antigravity/*` 代理模型集(Gemini 3.5 Flash 各档、Gemini 3.1 Pro、Claude Sonnet/Opus 4.6),绝不选择需要 `GEMINI_API_KEY` 的 public `google/gemini-*` 行。
41
+ - **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio,以及 20+ 个 OpenAI、Anthropic 兼容云服务(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai 等),统一在一个 JSON 工具循环中 — **还包括你自己注册的端点**: `jeo provider add --id my-proxy --base-url https://…`(或 `--preset litellm|vllm|sglang|azure-openai|…`)让 LiteLLM 代理、自托管 vLLM、企业内部 Anthropic 网关拥有各自的路由前缀、模型列表和凭据,而不是劫持内置的 `openai` 提供商。输入框内直接 OAuth 登录(`/provider login`),模型选择即刻持久化为默认值。Prompt routing 只会自动选择真实可用的凭据路径: Gemini OAuth 会走 provider-qualified 的 `antigravity/*` 代理模型集(Gemini 3.5 Flash 各档、Gemini 3.1 Pro、Claude Sonnet/Opus 4.6),绝不选择需要 `GEMINI_API_KEY` 的 public `google/gemini-*` 行。
42
42
  - **编辑完整性** — read 输出携带内容锚点(`42ab|`);带锚点的编辑会与当前文件校验、行移动时自动重映射、不匹配时连同最新内容一起拒绝 — 绝不污染文件。
43
43
  - **自我修正的验证循环** — 配置 post-edit 钩子(tsc / eslint / 测试),代理会*亲自读取*诊断并在循环内修复;钩子未通过时 `done` 会被阻断。
44
44
  - **没有表演的真实门禁** — `ralplan` 共识由真正读取仓库的 critic 子代理执行,`[OKAY]` 裁决被持久化且 `jeo approve` *强制要求*它;`ultragoal` 诚实报告(套件运行只是全局信号,绝不伪造逐条通过)。
@@ -87,6 +87,7 @@ jeo --tmux # 在独立 tmux 会话中运行
87
87
  | --- | --- |
88
88
  | `/model` · `/provider` | 选择模型/提供商;`/model` 在一个流程内显示默认/角色徽章、Ralph 风格嵌套角色·thinking 选择与 OpenAI Codex 角色预设 |
89
89
  | `/provider login <name>` · `/logout` | 在输入框内 OAuth 登录/登出 |
90
+ | `/provider add` · `list` · `remove` · `presets` | 将 OpenAI/Anthropic 兼容端点注册为一等提供商(15 个网关预设) |
90
91
  | `/agents [role]` · `/subagent` | 按角色(executor/planner/architect/critic)配置模型·thinking·步数 |
91
92
  | `/thinking [level]` | 查看/设置默认推理预算(low…xhigh) |
92
93
  | `/route [status\|on\|off\|why\|history [n]]` | 切换本会话的基于提示词的模型路由 · 解释最近一次路由决策 · `history [n]` 列出本会话最近 n 条(默认 10 条)路由决策(仅在已配置凭证 — OAuth 或 API 密钥 — 实际可用的模型内自动路由) |
@@ -246,6 +247,35 @@ JEO_TOOL_OUTPUT_MAX=4000 # 模型可见的工具输出上限(全文溢出
246
247
 
247
248
  重试行为通过 `~/.jeo/config.json` 的 `retry` 调整(`requestMaxRetries`、`streamMaxRetries`、`rateLimitRetries`、`failFastStatuses` 等)。步数预算默认动态 — 只要看到新的进展就延长,停滞时收敛为总结;`--max-steps N` 恢复有界流程。
248
249
 
250
+ ### 自定义提供商
251
+
252
+ ```jsonc
253
+ {
254
+ "customProviders": {
255
+ "my-proxy": {
256
+ "baseUrl": "https://gateway.internal/v1",
257
+ "protocol": "openai", // 或 "anthropic" (/v1/messages 协议)
258
+ "apiKeyEnv": "MY_PROXY_API_KEY", // 默认: <ID>_API_KEY
259
+ "models": ["fast", "smart"] // 离线列表;实时 /models 优先
260
+ }
261
+ }
262
+ }
263
+ ```
264
+
265
+ 命令行(`jeo provider add|list|remove|presets|test`)与输入框(`/provider add …`)操作同一份注册表。
266
+ `jeo provider test <id>` 会真实探测端点,并**分别报告凭据问题与端点问题**,
267
+ 失败时以非零码退出,可直接用作部署脚本的检查关卡。
268
+
269
+ ### 子代理扇出并发度
270
+
271
+ ```jsonc
272
+ { "subagentConcurrency": 2 } // 默认 4,限制在 1~16
273
+ ```
274
+
275
+ Anthropic Tier-1 账号在 4 条并发流时会立刻触发限流,使扇出变成比顺序执行**更慢**的
276
+ 退避风暴。限流紧张的账号请调低,额度充足时可调高。该值同时约束 `task` 工具的
277
+ `tasks[]` 批处理与 `eval` 工具的 `parallel`/`pipeline` 辅助函数。
278
+
249
279
  ## 技能迁移与内置技能检查
250
280
 
251
281
  把某个工作流迁移到 jeo 之前,先检查内置默认值,再决定是否安装或覆盖任何内容:
@@ -287,10 +317,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
287
317
 
288
318
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
289
319
  - **[Unreleased]**
320
+ - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
290
321
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
291
322
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
292
323
  - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
293
- - **[0.9.15]** (2026-08-18) — The 0.9.14 fix wired `promptInput` into the `$a $b …` one-shot skill-chain's `io.input`, but that chain runs before the interactive REPL's own `readline.Interface` exists — so a bundled workflow skill (`$deep-interview`, with or without `--tmux`) invoked directly from the command line still froze forever on its first question.
294
324
 
295
325
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
296
326
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -179,6 +179,12 @@ export const ConfigSchema = z
179
179
  * (executor / planner / architect / critic); each may pin a model and/or a
180
180
  * step budget. Tolerant of unknown keys.
181
181
  */
182
+ /** Max subagents a fan-out batch runs CONCURRENTLY (`task: {tasks:[...]}`) and the
183
+ * bound the eval tool's `parallel`/`pipeline` helpers enforce. Default 4.
184
+ * Lower it on a rate-limited account (4 concurrent Anthropic streams trips Tier-1
185
+ * limits immediately); raise it when the provider has headroom. Clamped to 1..16 —
186
+ * above that the parent turn is monopolized regardless of provider capacity. */
187
+ subagentConcurrency: z.number().int().min(1).max(16).optional(),
182
188
  subagents: z
183
189
  .record(
184
190
  z.object({
@@ -1,8 +1,78 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
 
4
5
  import { IGNORED_DIRS } from "./tools";
5
6
 
7
+ /**
8
+ * The user's home directory, or `undefined` when it genuinely cannot be determined.
9
+ *
10
+ * `$HOME` is NOT always set: bare Docker images, systemd units without `User=`,
11
+ * `env -i` wrappers and some CI runners all launch processes without it. Two separate
12
+ * bugs came out of reading `process.env.HOME` raw:
13
+ *
14
+ * 1. The parent walk below stops at the home directory. With `HOME` unset that guard
15
+ * was `null` and never fired, so the walk climbed PAST the user's home into
16
+ * `/Users`, `/home`, or `/` — loading whatever `AGENTS.md` / `CLAUDE.md` happened
17
+ * to sit there (on a shared machine, potentially another account's) straight into
18
+ * the system prompt.
19
+ * 2. `discoverAgentGuidanceFiles` built its home roots with
20
+ * `path.join(process.env.HOME || "", ".agents", "rules")`, and `path.join("", ...)`
21
+ * yields a RELATIVE path — so with `HOME` unset it scanned `./.agents/rules`,
22
+ * i.e. the current working directory, and treated a project's files as if they were
23
+ * the user's global config.
24
+ *
25
+ * `os.homedir()` consults the OS user database (getpwuid / USERPROFILE), so it still
26
+ * answers correctly when the env var is missing. It is only allowed to return a usable
27
+ * value — an empty or relative result is rejected rather than silently becoming ".".
28
+ */
29
+ export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
30
+ const candidates = [env.HOME, safeOsHomedir()];
31
+ for (const c of candidates) {
32
+ const trimmed = c?.trim();
33
+ if (!trimmed) continue;
34
+ const resolved = path.resolve(trimmed);
35
+ // `path.resolve` always returns an absolute path, but a bogus "."/"" input would
36
+ // resolve to the CWD — which is exactly the bug this function exists to prevent.
37
+ if (resolved === path.resolve(process.cwd()) && trimmed !== resolved) continue;
38
+ return resolved;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ function safeOsHomedir(): string | undefined {
44
+ try {
45
+ return os.homedir() || undefined;
46
+ } catch {
47
+ // os.homedir() throws on exotic platforms with no user database.
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ /** Hard ceiling on the parent walk. The home boundary and the git-root check normally
53
+ * stop it long before this; the cap is defence in depth so a pathological environment
54
+ * (no home, no git root, deeply nested cwd) can never walk the whole filesystem. */
55
+ const MAX_PARENT_WALK_DEPTH = 24;
56
+
57
+ /**
58
+ * True when `dir` is at or above the user's home directory — i.e. a shared/system path
59
+ * such as `/Users`, `/home` or `/`.
60
+ *
61
+ * The parent walk must never read agent context from these. Stopping only on
62
+ * `curr === home` was too weak: when the working directory is NOT inside home (a repo
63
+ * under `/opt`, or a temp dir whose `TMPDIR` points outside home) that equality never
64
+ * holds, and the walk climbs into directories that can belong to another account or to
65
+ * the machine itself — silently splicing their `AGENTS.md` / `CLAUDE.md` into the
66
+ * system prompt. Testing "is an ancestor of home" stops at the same place for the
67
+ * common case AND caps the uncommon one at the last non-shared directory.
68
+ */
69
+ function isAtOrAboveHome(dir: string, home: string | null): boolean {
70
+ if (!home) return false;
71
+ if (dir === home) return true;
72
+ // `home` lives under `dir` → `dir` is a shared ancestor (e.g. /Users for /Users/x).
73
+ return home.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
74
+ }
75
+
6
76
  interface ContextItem {
7
77
  filePath: string;
8
78
  displayPath: string;
@@ -225,15 +295,20 @@ export function invalidateWorkspaceScan(cwd?: string): void {
225
295
  export async function discoverAgentGuidanceFiles(cwd = process.cwd()): Promise<Array<{ filePath: string; displayPath: string }>> {
226
296
  const scan = await getWorkspaceScan(cwd);
227
297
  const out: Array<{ filePath: string; displayPath: string }> = [...scan.localGuidance];
228
- const home = path.join(process.env.HOME || "", "");
229
- const homeRoots = [
230
- { rootDir: path.join(home, ".agents", "rules"), displayPrefix: "~/.agents/rules" },
231
- { rootDir: path.join(home, ".jeo", "rules"), displayPrefix: "~/.jeo/rules" },
232
- { rootDir: path.join(home, ".agents", "hooks"), displayPrefix: "~/.agents/hooks" },
233
- { rootDir: path.join(home, ".jeo", "hooks"), displayPrefix: "~/.jeo/hooks" },
234
- ];
235
- for (const root of homeRoots) {
236
- await collectTextFiles(root.rootDir, root.displayPrefix, 2, out);
298
+ // No resolvable home → NO home roots. Previously this fell back to `path.join("", …)`,
299
+ // a RELATIVE path, so the "user global" scan silently read the current working
300
+ // directory's `.agents/rules` and presented project files as user-global config.
301
+ const home = resolveHomeDir();
302
+ if (home) {
303
+ const homeRoots = [
304
+ { rootDir: path.join(home, ".agents", "rules"), displayPrefix: "~/.agents/rules" },
305
+ { rootDir: path.join(home, ".jeo", "rules"), displayPrefix: "~/.jeo/rules" },
306
+ { rootDir: path.join(home, ".agents", "hooks"), displayPrefix: "~/.agents/hooks" },
307
+ { rootDir: path.join(home, ".jeo", "hooks"), displayPrefix: "~/.jeo/hooks" },
308
+ ];
309
+ for (const root of homeRoots) {
310
+ await collectTextFiles(root.rootDir, root.displayPrefix, 2, out);
311
+ }
237
312
  }
238
313
  const seen = new Set<string>();
239
314
  return out.filter(entry => {
@@ -261,7 +336,7 @@ export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectCo
261
336
  };
262
337
 
263
338
  const resolvedCwd = path.resolve(cwd);
264
- const home = process.env.HOME ? path.resolve(process.env.HOME) : null;
339
+ const home = resolveHomeDir() ?? null;
265
340
  const collectedItems: ContextItem[] = [];
266
341
 
267
342
  // 0. User-global context files (~/.jeo/agent/AGENTS.md) — lowest precedence.
@@ -293,7 +368,11 @@ export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectCo
293
368
  let curr = resolvedCwd;
294
369
  let distance = 0;
295
370
  while (true) {
296
- if (home && curr === home) {
371
+ if (isAtOrAboveHome(curr, home)) {
372
+ break;
373
+ }
374
+ // Never walk the entire filesystem, even if the home boundary is unknowable.
375
+ if (distance >= MAX_PARENT_WALK_DEPTH) {
297
376
  break;
298
377
  }
299
378
 
@@ -42,13 +42,52 @@ export function parseEtimeToMs(raw: string): number | undefined {
42
42
  return ((days * 24 + h!) * 60 + m!) * 60 * 1000 + s! * 1000;
43
43
  }
44
44
 
45
- /** Best-effort actual OS process start time in epoch ms, via `ps -o etime=`
46
- * (POSIX-portable across macOS/Linux `ps`; unsupported on Windows, where
47
- * there is no equivalent zero-dependency lookup). Returns `undefined` when
48
- * the lookup is unavailable or unparseable — callers MUST treat that as
49
- * "cannot verify" and fall back to the existence-only check, not as "dead". */
45
+ /**
46
+ * Read a process's start time from `/proc` — Linux only, no subprocess.
47
+ *
48
+ * Field 22 of `/proc/<pid>/stat` is the process start time in clock ticks since
49
+ * boot; `/proc/uptime` gives seconds since boot. Together they yield an epoch
50
+ * timestamp WITHOUT spawning anything. That matters because the `ps` path below is
51
+ * exactly what disappears in the environments a Telegram daemon actually runs in:
52
+ * distroless and scratch containers ship no `ps`, and hardened sandboxes/seccomp
53
+ * profiles routinely block the setuid `/bin/ps`. When the lookup dies there, the
54
+ * PID-reuse guard silently degrades to a bare `kill(pid, 0)` — see
55
+ * {@link isLockOwnerAlive} for why that is the dangerous case.
56
+ *
57
+ * The 22nd field is parsed from the LAST `)` rather than by splitting on spaces:
58
+ * field 2 is the executable name in parentheses and may itself contain spaces.
59
+ */
60
+ function procStartTimeMs(pid: number): number | undefined {
61
+ if (process.platform !== "linux") return undefined;
62
+ try {
63
+ const stat = fsSync.readFileSync(`/proc/${pid}/stat`, "utf-8");
64
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
65
+ const fields = afterComm.split(" ");
66
+ // stat field 22 (1-based) == index 19 of the post-comm remainder (fields 3+).
67
+ const startTicks = Number(fields[19]);
68
+ if (!Number.isFinite(startTicks)) return undefined;
69
+ const uptimeSec = Number(fsSync.readFileSync("/proc/uptime", "utf-8").split(" ")[0]);
70
+ if (!Number.isFinite(uptimeSec)) return undefined;
71
+ // USER_HZ is 100 on every Linux ABI that matters; sysconf(_SC_CLK_TCK) is not
72
+ // reachable without a native addon, and jeo is zero-native-dependency by design.
73
+ const startSecSinceBoot = startTicks / 100;
74
+ const bootEpochMs = Date.now() - uptimeSec * 1000;
75
+ return bootEpochMs + startSecSinceBoot * 1000;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /** Best-effort actual OS process start time in epoch ms. Tries `/proc` first (Linux,
82
+ * no subprocess, works in distroless/sandboxed containers), then `ps -o etime=`
83
+ * (POSIX-portable across macOS/Linux; unsupported on Windows, where there is no
84
+ * equivalent zero-dependency lookup). Returns `undefined` when the lookup is
85
+ * unavailable or unparseable — callers MUST treat that as "cannot verify" and fall
86
+ * back to the existence-only check, not as "dead". */
50
87
  export async function processStartTimeMs(pid: number): Promise<number | undefined> {
51
88
  if (process.platform === "win32") return undefined;
89
+ const viaProc = procStartTimeMs(pid);
90
+ if (viaProc !== undefined) return viaProc;
52
91
  const output = await new Promise<string | undefined>(resolve => {
53
92
  let child: ReturnType<typeof nodeSpawn>;
54
93
  try {
@@ -67,6 +106,13 @@ export async function processStartTimeMs(pid: number): Promise<number | undefine
67
106
  return ms === undefined ? undefined : Date.now() - ms;
68
107
  }
69
108
 
109
+ /** True when this host can actually verify a process's start time. Exported so
110
+ * status output (and tests) can distinguish "the guard says the owner is alive"
111
+ * from "the guard could not check" instead of conflating the two. */
112
+ export async function canVerifyProcessStartTime(): Promise<boolean> {
113
+ return (await processStartTimeMs(process.pid)) !== undefined;
114
+ }
115
+
70
116
  /** `etime` is truncated to whole seconds and adds a little spawn/poll jitter;
71
117
  * genuine PID reuse shows up as a gap of minutes/hours/days, never a few
72
118
  * seconds, so a generous-but-tight tolerance loses no real detections. */
@@ -82,10 +128,23 @@ const START_TIME_TOLERANCE_MS = 10_000;
82
128
  * unavailable (Windows, `ps` missing, permission denied) — never a
83
129
  * regression versus the old behavior in that case. */
84
130
  export async function isLockOwnerAlive(lock: DaemonLockInfo): Promise<boolean> {
85
- if (!isPidAlive(lock.pid)) return false;
131
+ return (await inspectLockOwner(lock)).alive;
132
+ }
133
+
134
+ /** Whether the recorded owner is alive, AND whether that answer was actually
135
+ * verified against the OS start time or merely assumed from `kill(pid, 0)`. */
136
+ export interface LockOwnerCheck {
137
+ alive: boolean;
138
+ /** False when the host could not report the process start time (no `/proc`,
139
+ * no usable `ps`, Windows) — `alive` is then an existence-only guess. */
140
+ verified: boolean;
141
+ }
142
+
143
+ export async function inspectLockOwner(lock: DaemonLockInfo): Promise<LockOwnerCheck> {
144
+ if (!isPidAlive(lock.pid)) return { alive: false, verified: true };
86
145
  const real = await processStartTimeMs(lock.pid);
87
- if (real === undefined) return true;
88
- return Math.abs(real - lock.startedAt) <= START_TIME_TOLERANCE_MS;
146
+ if (real === undefined) return { alive: true, verified: false };
147
+ return { alive: Math.abs(real - lock.startedAt) <= START_TIME_TOLERANCE_MS, verified: true };
89
148
  }
90
149
 
91
150
  export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
@@ -123,6 +182,10 @@ export interface DaemonStatus {
123
182
  stale: boolean;
124
183
  pid?: number;
125
184
  startedAt?: number;
185
+ /** False when this host cannot report a process start time (no `/proc`, no usable
186
+ * `ps`, Windows), so `running` is an existence-only guess that cannot rule out PID
187
+ * reuse. Present only when a lock file exists. */
188
+ ownerVerified?: boolean;
126
189
  }
127
190
 
128
191
  export async function isNotifyConfigured(): Promise<boolean> {
@@ -133,8 +196,10 @@ export async function isNotifyConfigured(): Promise<boolean> {
133
196
  export async function daemonStatus(): Promise<DaemonStatus> {
134
197
  const [lock, configured] = await Promise.all([readDaemonLock(), isNotifyConfigured()]);
135
198
  if (!lock) return { configured, running: false, stale: false };
136
- const alive = await isLockOwnerAlive(lock);
137
- return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt };
199
+ const { alive, verified } = await inspectLockOwner(lock);
200
+ // Surface the degraded case instead of hiding it: on a host with no `/proc` and no
201
+ // usable `ps`, "running" is an existence-only guess that cannot rule out PID reuse.
202
+ return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
138
203
  }
139
204
 
140
205
  /** Self-invocation argv for the daemon child (mirrors `memory.ts`'s
@@ -200,7 +265,8 @@ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<
200
265
 
201
266
  export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
202
267
  const lock = await readDaemonLock();
203
- if (!lock || !(await isLockOwnerAlive(lock))) {
268
+ const owner = lock ? await inspectLockOwner(lock) : undefined;
269
+ if (!lock || !owner?.alive) {
204
270
  await fs.unlink(notifyDaemonLockPath()).catch(() => {});
205
271
  return { ok: true, message: "daemon was not running" };
206
272
  }
@@ -212,7 +278,8 @@ export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
212
278
  const stopped = await waitFor(async () => !isPidAlive(lock.pid), 3_000);
213
279
  if (!stopped) return { ok: false, message: `daemon (pid ${lock.pid}) did not exit within 3s` };
214
280
  await fs.unlink(notifyDaemonLockPath()).catch(() => {});
215
- return { ok: true, message: `daemon stopped (was pid ${lock.pid})` };
281
+ const caveat = owner.verified ? "" : " (start time unverifiable on this host — PID reuse could not be ruled out)";
282
+ return { ok: true, message: `daemon stopped (was pid ${lock.pid})${caveat}` };
216
283
  }
217
284
 
218
285
  export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; message: string }> {
@@ -111,6 +111,27 @@ export interface TaskToolOptions {
111
111
  * no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
112
112
  export const MAX_FANOUT = 4;
113
113
 
114
+ /** Clamp for {@link resolveFanoutLimit}. Below 1 there is no fan-out at all; above 16
115
+ * the parent turn is monopolized no matter how much provider capacity exists. */
116
+ export const MIN_FANOUT_LIMIT = 1;
117
+ export const MAX_FANOUT_LIMIT = 16;
118
+
119
+ /**
120
+ * Effective fan-out concurrency for this account.
121
+ *
122
+ * {@link MAX_FANOUT} is a reasonable default, not a universal one: four concurrent
123
+ * Anthropic streams trip a Tier-1 rate limit immediately (turning a fan-out into a
124
+ * backoff storm that is SLOWER than running sequentially), while an account with real
125
+ * headroom leaves throughput on the table at four. `config.subagentConcurrency` lets
126
+ * that be tuned per machine; the clamp keeps a typo (`0`, `-1`, `500`) from producing a
127
+ * batch that never runs or never yields.
128
+ */
129
+ export function resolveFanoutLimit(config?: { subagentConcurrency?: number }): number {
130
+ const raw = config?.subagentConcurrency;
131
+ if (raw === undefined || !Number.isFinite(raw)) return MAX_FANOUT;
132
+ return Math.min(MAX_FANOUT_LIMIT, Math.max(MIN_FANOUT_LIMIT, Math.trunc(raw)));
133
+ }
134
+
114
135
  /** Small bounded reroute budget for a SUBAGENT's own 429 (distinct from
115
136
  * launch.ts's `ROUTE_FALLBACK_MAX_ATTEMPTS`, default 3): a subagent is already
116
137
  * bounded by its own step/token budget and the parent turn's patience, so this
@@ -635,9 +656,12 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
635
656
  };
636
657
  }
637
658
  }
638
- // Both roles fan out CONCURRENTLY, bounded at MAX_FANOUT (gjc parity — see
639
- // MAX_FANOUT's docstring for the disjoint-file-scope expectation this relies on).
640
- const limit = Math.min(items.length, MAX_FANOUT);
659
+ // Both roles fan out CONCURRENTLY, bounded at the account's effective limit
660
+ // (gjc parity — see MAX_FANOUT's docstring for the disjoint-file-scope expectation
661
+ // this relies on). `config.subagentConcurrency` tunes it per machine so a
662
+ // rate-limited account can drop below the default instead of backoff-storming.
663
+ const fanoutLimit = resolveFanoutLimit(opts.config as { subagentConcurrency?: number });
664
+ const limit = Math.min(items.length, fanoutLimit);
641
665
  // Load project context ONCE per batch instead of re-scanning AGENTS.md for
642
666
  // every fan-out task (redundant IO + duplicated tokens).
643
667
  const batchContext = await loadProjectContext(cwd);
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Retired model ids and where they went.
3
+ *
4
+ * A model id that a provider has sunset does not fail *nicely*: the id is simply
5
+ * unknown at the API, so the call comes back as a 404 / "model not found" from deep
6
+ * inside the adapter. That is a bad failure for jeo specifically, because model ids get
7
+ * **pinned in three durable places** — `config.defaultModel`, per-role
8
+ * `config.subagents.<role>.model`, and `config.roles.*` — and a pinned id keeps failing
9
+ * every turn until the user works out which of the three is stale. The live catalog
10
+ * refresh does not help: discovery only reports what still EXISTS, so a retired pin has
11
+ * nothing to match against and silently stays broken.
12
+ *
13
+ * This table maps a retired id to its documented successor so the manager can (a) route
14
+ * the call to something that works and (b) tell the user exactly which pin to update.
15
+ * It is deliberately small and hand-curated: only ids jeo itself shipped in its catalog
16
+ * or aliases, so nobody's config can be silently rewritten to a model they never chose.
17
+ *
18
+ * Rules for entries:
19
+ * - `replacement` MUST be a currently-catalogued id, and MUST stay on the SAME provider.
20
+ * Silently moving a user to a different vendor (different pricing, different data
21
+ * handling, different account) is not a substitution jeo gets to make on its own.
22
+ * - `note` is shown to the user once per session, so it should name the successor and
23
+ * read as an explanation, not a warning banner.
24
+ */
25
+
26
+ export interface ModelRetirement {
27
+ /** The retired id, exactly as it appeared in jeo's catalog/aliases. */
28
+ readonly retired: string;
29
+ /** Current catalog id that serves the same role on the SAME provider. */
30
+ readonly replacement: string;
31
+ /** One-line reason surfaced to the user. */
32
+ readonly note: string;
33
+ }
34
+
35
+ export const MODEL_RETIREMENTS: readonly ModelRetirement[] = [
36
+ {
37
+ retired: "antigravity/gemini-3.1-pro-high",
38
+ replacement: "antigravity/gemini-pro-agent",
39
+ note: "Antigravity retired gemini-3.1-pro-high; gemini-pro-agent is its code-agent successor.",
40
+ },
41
+ {
42
+ retired: "gemini-3.1-pro-high",
43
+ replacement: "antigravity/gemini-pro-agent",
44
+ note: "Antigravity retired gemini-3.1-pro-high; gemini-pro-agent is its code-agent successor.",
45
+ },
46
+ ] as const;
47
+
48
+ const BY_RETIRED = new Map(MODEL_RETIREMENTS.map(r => [r.retired.toLowerCase(), r]));
49
+
50
+ /** The retirement record for `model`, or undefined when the id is still current. */
51
+ export function findRetirement(model: string | undefined): ModelRetirement | undefined {
52
+ const key = model?.trim().toLowerCase();
53
+ return key ? BY_RETIRED.get(key) : undefined;
54
+ }
55
+
56
+ /** True when `model` names an id jeo knows has been sunset. */
57
+ export function isRetiredModel(model: string | undefined): boolean {
58
+ return findRetirement(model) !== undefined;
59
+ }
60
+
61
+ /**
62
+ * Map a possibly-retired id onto a working one.
63
+ *
64
+ * Returns the input unchanged when it is current, so this is safe to call on every
65
+ * resolution path. Chains are followed (a successor that was itself later retired)
66
+ * with a hard bound, so a malformed table can never spin here.
67
+ */
68
+ export function resolveRetiredModel(model: string): { model: string; retirement?: ModelRetirement } {
69
+ let current = model;
70
+ let first: ModelRetirement | undefined;
71
+ for (let hop = 0; hop < 4; hop++) {
72
+ const hit = findRetirement(current);
73
+ if (!hit) break;
74
+ first ??= hit;
75
+ if (hit.replacement.toLowerCase() === current.toLowerCase()) break; // self-reference guard
76
+ current = hit.replacement;
77
+ }
78
+ return first ? { model: current, retirement: first } : { model };
79
+ }
80
+
81
+ /**
82
+ * Which durable config pins still name a retired model, and what to change them to.
83
+ * Used by `jeo doctor` and by the launch banner so the user is told *where* the stale
84
+ * id lives rather than just that some call failed.
85
+ */
86
+ export interface RetiredPin {
87
+ /** Human-readable location, e.g. `defaultModel` or `subagents.executor.model`. */
88
+ location: string;
89
+ retired: string;
90
+ replacement: string;
91
+ note: string;
92
+ }
93
+
94
+ export function findRetiredPins(config: {
95
+ defaultModel?: string;
96
+ roles?: Record<string, string | undefined>;
97
+ subagents?: Record<string, { model?: string } | undefined>;
98
+ }): RetiredPin[] {
99
+ const pins: RetiredPin[] = [];
100
+ const add = (location: string, model: string | undefined) => {
101
+ const hit = findRetirement(model);
102
+ if (hit) pins.push({ location, retired: model!, replacement: hit.replacement, note: hit.note });
103
+ };
104
+ add("defaultModel", config.defaultModel);
105
+ for (const [role, model] of Object.entries(config.roles ?? {})) add(`roles.${role}`, model);
106
+ for (const [role, cfg] of Object.entries(config.subagents ?? {})) add(`subagents.${role}.model`, cfg?.model);
107
+ return pins;
108
+ }
109
+
110
+ /** One-time-per-session dedupe so a retired pin explains itself once, not every turn. */
111
+ const announced = new Set<string>();
112
+
113
+ export function shouldAnnounceRetirement(retired: string): boolean {
114
+ const key = retired.toLowerCase();
115
+ if (announced.has(key)) return false;
116
+ announced.add(key);
117
+ return true;
118
+ }
119
+
120
+ /** Test hook — clears the per-session announce dedupe. */
121
+ export function resetRetirementAnnouncements(): void {
122
+ announced.clear();
123
+ }
@@ -12,6 +12,14 @@ async function printStatus(): Promise<void> {
12
12
  }
13
13
  if (status.running) {
14
14
  console.log(`running (pid ${status.pid}, started ${new Date(status.startedAt!).toISOString()})`);
15
+ // Say so when the PID-reuse guard could not actually run. On a host with no `/proc`
16
+ // and no usable `ps` (distroless images, seccomp-hardened sandboxes) "running" is an
17
+ // existence-only guess: the pid is alive, but it may belong to an unrelated process
18
+ // that inherited it. Silently printing "running" there overstates what jeo checked.
19
+ if (status.ownerVerified === false) {
20
+ console.log(" note: this host cannot report process start times, so PID reuse could not be ruled out.");
21
+ console.log(" 'jeo daemon stop' may therefore be signalling a different process than the daemon.");
22
+ }
15
23
  } else if (status.stale) {
16
24
  console.log("stale — a lock file exists but its pid is dead (previous daemon crashed without cleanup). 'jeo daemon start' will reclaim it.");
17
25
  } else {
@@ -1,4 +1,5 @@
1
1
  import { readGlobalConfig } from "../agent/state";
2
+ import { findRetiredPins } from "../ai/model-retirements";
2
3
  import { resolveCredential, snapshotProvider, type AuthProvider, type Credential } from "../auth";
3
4
  import { resolveProvider, describeProvider } from "../ai";
4
5
 
@@ -444,6 +445,19 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
444
445
  for (const line of oauthLines) console.log(line);
445
446
  console.log("");
446
447
  }
448
+ // Retired-model pins: informational only, but the ONE diagnostic a user cannot get
449
+ // any other way. Alias expansion silently reroutes a sunset id to its successor, so a
450
+ // stale pin keeps working and the user is never told their config is out of date —
451
+ // until the alias is eventually dropped and every turn starts 404-ing. Name the exact
452
+ // config key so the fix is a one-line edit rather than a hunt through three places.
453
+ const retiredPins = findRetiredPins(config as Parameters<typeof findRetiredPins>[0]);
454
+ if (retiredPins.length) {
455
+ for (const pin of retiredPins) {
456
+ console.log(`${chalk.yellow("[model]")} ${pin.location} pins retired '${pin.retired}' — ${pin.note}`);
457
+ console.log(` update it to '${pin.replacement}'.`);
458
+ }
459
+ console.log("");
460
+ }
447
461
  // Routing diagnostic: informational only — never affects ready/strict exit logic.
448
462
  if (routingEnabled) {
449
463
  if (routingNotes.length) {