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 +22 -0
- package/README.ja.md +34 -2
- package/README.ko.md +34 -2
- package/README.md +35 -3
- package/README.zh.md +32 -2
- package/package.json +1 -1
- package/src/agent/config-schema.ts +6 -0
- package/src/agent/context-files.ts +90 -11
- package/src/agent/notify/daemon-control.ts +79 -12
- package/src/agent/task-tool.ts +27 -3
- package/src/ai/model-retirements.ts +123 -0
- package/src/commands/daemon.ts +8 -0
- package/src/commands/doctor.ts +14 -0
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
|
|
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 도구
|
|
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
|
|
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
|
@@ -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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }> {
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -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
|
|
639
|
-
// MAX_FANOUT's docstring for the disjoint-file-scope expectation
|
|
640
|
-
|
|
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
|
+
}
|
package/src/commands/daemon.ts
CHANGED
|
@@ -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 {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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) {
|