jeo-code 0.7.51 → 0.7.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.7.52] - 2026-07-06
10
+ _Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code._
11
+
12
+ ### Added
13
+ - **Persistent routed-tier marker in the status bar** (`src/tui/components/status.ts`'s `StatusBarData.routedTier`/`renderStatusBar`, threaded through `LaunchTuiOptions`/`LaunchTui` in `src/tui/app.ts` and `src/commands/launch.ts`'s `runTurn`) — when PromptRouter picks THIS turn's model, a `⚡tier` marker (`~tier` in ASCII mode) renders right after the model name in the always-visible model bar, e.g. `claude-haiku-4-5 ⚡trivial`. Unlike the existing `[route] …` console notice (which intentionally stays silent for routine tier resolutions and only fires for interesting/escalated cases), the status-bar marker is unconditional whenever routing actually chose the model this turn — omitted when routing is off, the model is pinned, or the v0.7.51 credential-readiness gate vetoed the routed decision back to the default.
14
+ - **Subagent thinking visibility** (`TaskSubEvent`'s new `"thinking"` kind in `src/agent/task-tool.ts`, wired through `runSubagentOnce`'s `onReasoningStream` callback and handled in `LaunchTui.onSubagentEvent`) — a delegated subagent's own native extended-thinking stream now surfaces as a live, per-slot preview in the TUI (mirrors the main turn's dimmed "Thinking" block, scoped to that subagent's row instead of a single shared region), tail-sliced and whitespace-collapsed the same way tool/step previews already are. Transient only: never persisted to the ledger/scrollback (matches how the main turn's live reasoning preview also never appears in the final record), and the non-TTY streaming log (`formatTaskSubEvent` in `src/commands/launch/stream.ts`) explicitly no-ops on `"thinking"` instead of falling through to a bogus `"ROLE done: <reasoning text>"` line.
15
+
16
+ ### Fixed
17
+ - **`retry.failFastStatuses`/`retry.failFastPatterns` silently stripped from every saved config** (`src/agent/config-schema.ts`) — both fields existed on `Config.retry` (`state.ts`) and were actively read by `src/ai/model-manager.ts` to decide non-retryable errors, but were never declared on the NESTED `retry` `z.object`. Only the outer `ConfigSchema` calls `.passthrough()`; Zod's default strip-unknown-keys behavior silently dropped both fields on every config read/write that round-tripped through `parseConfig`, even though `config.json` faithfully stored them on disk — a user-configured fail-fast rule would quietly stop applying the moment anything else touched the config file. Fixed by declaring both fields on the nested object directly.
18
+ - **Ponytail-mode dead-code sweep** (six files, five independent findings, all confirmed zero-call-site via `grep` before deletion): `src/agent/output-util.ts` lost `TOOL_SPILL_THRESHOLD`, `truncateToolOutput`, `spillToolResult`, and `logPerformanceMetric` (the last of these means `.jeo/state/performance-metrics.json` is never actually written by anything in this codebase — `PerfMetric`'s read-side in `dev/self-analysis.ts` is kept as the interface contract, but the writer was dead weight, not a working feature); `src/agent/ts-language-service.ts` lost `resetLanguageServiceCache` (no caller); `src/agent/task-tool.ts` lost the `@deprecated` `TASK_TOOL_PROTOCOL_LINE` static snapshot (superseded by `taskToolProtocolLine(config)`); `src/agent/step-budget.ts` simplified a no-op `key.replace(/^JEO_/, "JEO_")` (replacing a prefix with itself) in both `envNum` and `envSet` to a plain lookup; `src/agent/opik-tracer.ts` removed a `try/finally` around `onStep` whose `finally` block was an empty comment with no actual cleanup; `src/ai/model-manager.ts`'s `composeAbort` dropped an unreachable manual-polyfill branch for `AbortSignal.any` (native since Bun 1.1.13; `src/cli.ts` hard-enforces `engines.bun: ">=1.3.14"` at process start), cutting it from 17 lines to 2 with identical behavior for every real input.
19
+
20
+ ### Verified
21
+ - `bun run typecheck` clean; `bun test` 2660 pass / 0 fail across 280 files on first run; a second full run reproduced 1 pre-existing flake (`test/hooks.test.ts`'s "disabled hooks never run", isolated-run-clean 3/3 — a cross-file `process.env.JEO_CONFIG_DIR` race under full-suite concurrency, not a regression from any change in this release).
22
+ - `test/status-bar.test.ts` (+4 tests): `routedTier` renders the `⚡` marker right after the model and before thinking; omitted entirely when routing didn't engage; ASCII fallback uses `~`; all three tier values (`trivial`/`standard`/`complex`) render verbatim.
23
+ - `test/tui-app.test.ts` (+3): `LaunchTui` threads `routedTier` into the persistent inline model bar and omits it when unset; `onSubagentEvent` with kind `"thinking"` drives the live per-slot preview but is never captured in the final scrollback record.
24
+ - `test/task-tool.test.ts` (+2): a subagent whose mocked `callLlm` invokes `onReasoning` emits live `"thinking"` events tagged to its own slot/index; a subagent with no reasoning stream emits zero `"thinking"` events (no false positives).
25
+ - `test/config-schema.test.ts` (+2, regression-focused): `retry.failFastStatuses`/`failFastPatterns` now survive `parseConfig` round-trip; a non-integer entry in `failFastStatuses` is rejected. The bug was confirmed empirically (bypassing the fix, both fields stripped silently) before applying it.
26
+ - Every dead-code deletion re-verified zero call sites via `grep` across `src/` and `test/` immediately before removal; the env-signal-composition simplifications (`composeAbort`, `envNum`/`envSet`) are exercised indirectly through existing abort-signal tests (`test/engine.test.ts`, `test/retry.test.ts`, `test/transient-network-recovery.test.ts`) and env-override tests (`test/step-budget.test.ts`), all passing unchanged — proving each simplification is behavior-preserving, not just "looks safe."
27
+
9
28
  ## [0.7.51] - 2026-07-06
10
29
  _Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole._
11
30
 
package/README.ja.md CHANGED
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
259
259
  ## 変更履歴 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
262
263
  - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
263
264
  - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
264
265
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
265
266
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
266
- - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -260,11 +260,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
260
260
  ## 변경 이력 (Changelog)
261
261
 
262
262
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
263
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
263
264
  - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
264
265
  - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
265
266
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
266
267
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
267
- - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
268
268
 
269
269
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
270
270
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -262,11 +262,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
262
262
  ## Changelog
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
265
266
  - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
266
267
  - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
267
268
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
268
269
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
269
- - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
259
259
  ## 更新日志 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
262
263
  - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
263
264
  - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
264
265
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
265
266
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
266
- - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.51",
3
+ "version": "0.7.52",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -140,6 +140,18 @@ export const ConfigSchema = z
140
140
  rateLimitRetries: z.number().int().min(0).optional(),
141
141
  rateLimitMinDelayMs: z.number().int().min(0).optional(),
142
142
  rateLimitMaxServerDelayMs: z.number().int().min(0).optional(),
143
+ /** HTTP statuses to treat as NON-retryable even when defaultRetryable would
144
+ * retry them (e.g. pin 503 to fail fast instead of riding the backoff ladder).
145
+ * Bug fix: this field (and failFastPatterns below) existed on Config.retry in
146
+ * state.ts and was actively read by model-manager.ts, but was never declared
147
+ * on THIS nested z.object — since only the OUTER ConfigSchema calls
148
+ * `.passthrough()`, Zod's default strip-unknown-keys behavior silently dropped
149
+ * both fields on every config read/write that round-tripped through this
150
+ * schema, even though the user's config.json faithfully stored them on disk. */
151
+ failFastStatuses: z.array(z.number().int()).optional(),
152
+ /** Case-insensitive substrings; an error whose message matches any of these
153
+ * fails fast (non-retryable) even when the chosen predicate would retry it. */
154
+ failFastPatterns: z.array(z.string()).optional(),
143
155
  })
144
156
  .optional(),
145
157
  /**
@@ -337,7 +337,7 @@ export function wrapEvents(events: AgentLoopEvents | undefined, tracer: OpikTrac
337
337
  onStep(step: number) {
338
338
  currentStep = step;
339
339
  stepStartedAt = Date.now();
340
- try { base.onStep?.(step); } finally { /* tracer has no per-onStep write */ }
340
+ base.onStep?.(step);
341
341
  },
342
342
  onAssistant(raw: string, invocation: ToolInvocation | null) {
343
343
  base.onAssistant?.(raw, invocation);
@@ -1,64 +1,9 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
-
4
- export const TOOL_SPILL_THRESHOLD = 32_000;
5
-
6
- export function truncateToolOutput(output: string, limit = 8000): string {
7
- if (output.length <= limit) return output;
8
- const half = Math.floor(limit / 2);
9
- return output.slice(0, half) + "\n\n... (elided " + (output.length - limit) + " chars) ...\n\n" + output.slice(-half);
10
- }
11
-
12
- export async function spillToolResult(tool: string, output: string, cwd: string): Promise<string> {
13
- const artifactsDir = path.join(cwd, ".jeo", "artifacts");
14
- await fs.mkdir(artifactsDir, { recursive: true });
15
-
16
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
17
- const filename = "result-" + tool + "-" + ts + ".txt";
18
- const fullPath = path.join(artifactsDir, filename);
19
-
20
- await fs.writeFile(fullPath, output, "utf-8");
21
- return path.relative(cwd, fullPath);
22
- }
23
-
24
- /** One tool execution's performance record (consumed by dev self-analysis). */
1
+ /** One tool execution's performance record (consumed by dev self-analysis's read
2
+ * path — `.jeo/state/performance-metrics.json`, currently never populated by
3
+ * any writer in this codebase; the shape is kept as the read-side contract). */
25
4
  export interface PerfMetric {
26
5
  tool: string;
27
6
  duration: number;
28
7
  success: boolean;
29
8
  error?: string;
30
9
  }
31
-
32
- /** Append a tool performance metric to `.jeo/state/performance-metrics.json`
33
- * (bounded to the most recent 200 records). Best-effort: failures are ignored
34
- * so metrics can never break an agent turn. */
35
- export async function logPerformanceMetric(cwd: string, metric: PerfMetric): Promise<void> {
36
- try {
37
- const dir = path.join(cwd, ".jeo", "state");
38
- const file = path.join(dir, "performance-metrics.json");
39
- await fs.mkdir(dir, { recursive: true });
40
- let records: PerfMetric[] = [];
41
- try {
42
- const parsed = JSON.parse(await fs.readFile(file, "utf-8"));
43
- if (Array.isArray(parsed)) records = parsed;
44
- } catch { /* first write or corrupt file → start fresh */ }
45
- records.push(metric);
46
- if (records.length > 200) records = records.slice(-200);
47
- await fs.writeFile(file, JSON.stringify(records), "utf-8");
48
- } catch { /* best-effort */ }
49
- }
50
-
51
- /** Read spec-kit project context (`.specify/memory/constitution.md`) when the repo
52
- * was initialized with GitHub spec-kit, so the agent honors the project
53
- * constitution. Returns null when absent/unreadable; content is capped. */
54
- export async function loadSpecKitContext(cwd: string): Promise<string | null> {
55
- try {
56
- const file = path.join(cwd, ".specify", "memory", "constitution.md");
57
- const content = (await fs.readFile(file, "utf-8")).trim();
58
- if (!content) return null;
59
- const capped = content.length > 4000 ? content.slice(0, 4000) + "…" : content;
60
- return "\n\n<spec-kit-constitution>\n" + capped + "\n</spec-kit-constitution>";
61
- } catch {
62
- return null;
63
- }
64
- }
@@ -41,8 +41,7 @@ export interface ExtensionDecision {
41
41
  type EnvLike = Record<string, string | undefined>;
42
42
 
43
43
  function envNum(env: EnvLike, key: string, dflt: number, min: number, max: number): number {
44
- // `key` is the legacy JEO_* name; the JEO_* spelling is preferred when both are set.
45
- const raw = env[key.replace(/^JEO_/, "JEO_")] ?? env[key];
44
+ const raw = env[key];
46
45
  if (raw === undefined || raw === "") return dflt;
47
46
  const n = Number(raw);
48
47
  if (!Number.isFinite(n)) return dflt;
@@ -82,7 +81,7 @@ export function resolveStepBudgetConfig(
82
81
 
83
82
  /** True when `key` carries a non-empty value in `env`. */
84
83
  function envSet(env: EnvLike, key: string): boolean {
85
- const raw = env[key.replace(/^JEO_/, "JEO_")] ?? env[key];
84
+ const raw = env[key];
86
85
  return raw !== undefined && raw !== "";
87
86
  }
88
87
 
@@ -35,7 +35,11 @@ import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notif
35
35
  /** Lifecycle event emitted while a delegated subagent runs. */
36
36
  export interface TaskSubEvent {
37
37
  role: string;
38
- kind: "start" | "step" | "tool" | "done" | "error";
38
+ /** `"thinking"` streams the subagent's live reasoning/thought text (native
39
+ * extended-thinking models) — a transient live-preview beat, not persisted to
40
+ * the ledger (mirrors the main turn's dimmed "Thinking" block, scoped per
41
+ * subagent slot instead of a single shared region). */
42
+ kind: "start" | "step" | "tool" | "done" | "error" | "thinking";
39
43
  detail?: string;
40
44
  success?: boolean;
41
45
  /** Current nested subagent step, when known. */
@@ -142,9 +146,6 @@ export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string
142
146
  );
143
147
  }
144
148
 
145
- /** @deprecated static snapshot (bundled roles only) — prefer taskToolProtocolLine(config). */
146
- export const TASK_TOOL_PROTOCOL_LINE = taskToolProtocolLine();
147
-
148
149
  /**
149
150
  * A concise, gjc-style label for a subagent's tool call — the actual TARGET (file / command /
150
151
  * glob), not just the bare tool name — so the parent's live monitor shows "read src/x.ts" or
@@ -300,6 +301,17 @@ export async function runSubagentOnce(
300
301
  tools: subagentToolset(role),
301
302
  events: {
302
303
  onStep: n => { currentStep = n; },
304
+ // Live reasoning preview (native extended-thinking models only — the JSON-protocol
305
+ // "reasoning" field the main turn also extracts from onModelStream is intentionally
306
+ // NOT wired here: a subagent's forming tool-call JSON is rarely useful mid-stream and
307
+ // doubling the stream sinks would double emit() calls for the same underlying delta).
308
+ // Tail-sliced + whitespace-collapsed to a compact one-line preview — the ledger never
309
+ // records this (see the `kind` doc comment); it only drives the live per-slot status.
310
+ onReasoningStream: textSoFar => {
311
+ const tail = textSoFar.length > 200 ? textSoFar.slice(textSoFar.length - 200) : textSoFar;
312
+ const preview = tail.replace(/\s+/g, " ").trim();
313
+ if (preview) emit({ role: role.id, kind: "thinking", detail: preview, step: currentStep, maxSteps, model });
314
+ },
303
315
  onAssistant: (_raw, invocation) => {
304
316
  if (invocation && invocation.tool && invocation.tool !== "done") {
305
317
  lastTarget = toolTarget(invocation.tool, invocation.arguments);
@@ -108,13 +108,6 @@ export async function languageServiceFor(cwd: string, absPath: string): Promise<
108
108
  return cached.service;
109
109
  }
110
110
 
111
- /** Drop a cached language service (tests / long-running sessions that need a
112
- * clean project graph after large structural changes). */
113
- export function resetLanguageServiceCache(cwd?: string): void {
114
- if (cwd) serviceCache.delete(cwd);
115
- else serviceCache.clear();
116
- }
117
-
118
111
  export interface SymbolSelector { text: string; occurrence: number }
119
112
 
120
113
  /** Parse a loose `"name"` or `"name#N"` selector (1-indexed occurrence, default 1). */
@@ -472,25 +472,12 @@ const DEFAULT_CALL_TIMEOUT_MS = 300_000;
472
472
  * phase runs longer still; Ctrl-C remains the interactive escape for a truly dead one. */
473
473
  const STREAM_IDLE_TIMEOUT_MS = 300_000;
474
474
 
475
- /** Combine two abort signals into one. Preserves BOTH even when `AbortSignal.any`
476
- * is unavailable (manual fallback), so neither the caller's cancel nor the timeout
477
- * is silently dropped. */
475
+ /** Combine two abort signals into one — neither the caller's cancel nor the timeout
476
+ * is ever silently dropped. `AbortSignal.any` is native on every Bun this CLI runs on
477
+ * (shipped in Bun 1.1.13; `src/cli.ts` hard-enforces `engines.bun: ">=1.3.14"` at
478
+ * process start, so a manual polyfill fallback would be permanently unreachable). */
478
479
  function composeAbort(a: AbortSignal | undefined, b: AbortSignal): AbortSignal {
479
- if (!a) return b;
480
- if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]);
481
- if (a.aborted || b.aborted) return AbortSignal.abort();
482
- const ctrl = new AbortController();
483
- // Memory hygiene: `a` is typically the TURN-long abort signal — a once-listener
484
- // per model call would otherwise accumulate on it for the whole turn. Detach
485
- // BOTH listeners as soon as either side fires.
486
- const onAbort = () => {
487
- a.removeEventListener("abort", onAbort);
488
- b.removeEventListener("abort", onAbort);
489
- ctrl.abort();
490
- };
491
- a.addEventListener("abort", onAbort, { once: true });
492
- b.addEventListener("abort", onAbort, { once: true });
493
- return ctrl.signal;
480
+ return a ? AbortSignal.any([a, b]) : b;
494
481
  }
495
482
 
496
483
  /** Compose the caller's signal (if any) with a fresh per-attempt timeout. */
@@ -53,11 +53,18 @@ export function formatTaskSubEvent(e: TaskSubEvent): string {
53
53
  if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
54
54
  if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
55
55
  if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
56
+ // "thinking" is a live-only TUI preview (never persisted to the ledger — see
57
+ // TaskSubEvent.kind's doc comment) and has no place in an append-only, non-TTY
58
+ // log stream: a reasoning delta stream would otherwise fall through to the
59
+ // "done" branch below and print a bogus "ROLE done: <reasoning text>" line for
60
+ // every emitted preview. Empty string signals the caller to skip the line.
61
+ if (e.kind === "thinking") return "";
56
62
  return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
57
63
  }
58
64
 
59
65
  export function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
60
- log(formatTaskSubEvent(e));
66
+ const line = formatTaskSubEvent(e);
67
+ if (line) log(line);
61
68
  }
62
69
 
63
70
  export function createStreamEvents(
@@ -937,7 +937,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
937
937
  // config file. (gjc parity P1.B5: dirty count per-turn, not per-render.)
938
938
  const { provider: activeProvider } = await describeModel(activeModel, turnConfig);
939
939
  const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
940
- const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking }) : null;
940
+ const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking, routedTier: routed?.tier }) : null;
941
941
  if (routeCredentialNotice) {
942
942
  if (tui) tui.events().onNotice?.(routeCredentialNotice);
943
943
  else console.log(routeCredentialNotice);
package/src/tui/app.ts CHANGED
@@ -68,6 +68,12 @@ export interface LaunchTuiOptions {
68
68
  dirtyCount?: number;
69
69
  /** Thinking-level label ("high", …) for the gjc-style model status bar. */
70
70
  thinking?: string;
71
+ /** PromptRouter's resolved tier when routing chose THIS turn's `model` (undefined
72
+ * when routing didn't engage — pinned model, routing off, or the credential
73
+ * gate fell back). Renders a persistent `⚡tier` marker in the status bar so the
74
+ * routed model stays visible without depending on the transient `[route]`
75
+ * console notice. See `StatusBarData.routedTier` in components/status.ts. */
76
+ routedTier?: "trivial" | "standard" | "complex";
71
77
  }
72
78
 
73
79
  export interface AgentEventsLike {
@@ -361,6 +367,9 @@ export class LaunchTui {
361
367
  private readonly inline: boolean;
362
368
  // Thinking-level label for the gjc-style model status bar.
363
369
  private readonly thinkingLevel?: string;
370
+ // PromptRouter's resolved tier when routing chose this turn's model; undefined when
371
+ // routing didn't engage. Renders the persistent `⚡tier` marker in the status bar.
372
+ private readonly routedTier?: "trivial" | "standard" | "complex";
364
373
 
365
374
  constructor(opts: LaunchTuiOptions) {
366
375
  // Backpressure-aware wrapper around the underlying writer (real stdout, or the
@@ -383,6 +392,7 @@ export class LaunchTui {
383
392
  this.renderer = new Renderer(this.write, undefined, { reserve: this.inline });
384
393
  this.spinner = new Spinner(undefined, { unicode: this.unicode });
385
394
  this.thinkingLevel = opts.thinking;
395
+ this.routedTier = opts.routedTier;
386
396
  this.footer = {
387
397
  model: opts.model,
388
398
  provider: opts.provider,
@@ -1041,6 +1051,11 @@ export class LaunchTui {
1041
1051
  touchSlot(`${roleLabel} ${bad} ${detail || "error"}`);
1042
1052
  this.appendLedger(` ${badge} ${branch} ${roleLabel} ${bad} ${detail || "error"}\n`, "subagent");
1043
1053
  break;
1054
+ case "thinking":
1055
+ // Live-only preview (mirrors the main turn's dimmed "Thinking" block) — never
1056
+ // persisted to the ledger; see TaskSubEvent.kind's doc comment for why.
1057
+ touchSlot(`${roleLabel} ${this.unicode ? "…" : "..."} ${detail || "thinking"}`);
1058
+ break;
1044
1059
  case "done":
1045
1060
  // Clear ONLY this slot — a fan-out batch where one worker finishes early
1046
1061
  // must keep showing its still-running siblings, not drop the `(sub)`
@@ -1632,6 +1647,7 @@ export class LaunchTui {
1632
1647
  return renderStatusBar({
1633
1648
  model: `${this.footer.model}${this.footer.provider ? ` (${this.footer.provider})` : ""}`,
1634
1649
  thinking: this.thinkingLevel,
1650
+ routedTier: this.routedTier,
1635
1651
  branch: this.footer.branch,
1636
1652
  dirtyCount: this.footer.dirtyCount,
1637
1653
  cwd: this.footer.cwd,
@@ -17,6 +17,14 @@ export interface StatusBarData {
17
17
  model: string;
18
18
  /** Thinking level label ("high", …); omitted when unset. */
19
19
  thinking?: string;
20
+ /** PromptRouter's resolved tier ("trivial"/"standard"/"complex") when THIS turn's
21
+ * model was chosen by routing rather than the session/default model directly —
22
+ * omitted when routing didn't engage this turn (pinned model, routing off, or
23
+ * the credential-readiness gate fell back). Renders as a persistent `⚡tier`
24
+ * marker so the routed model stays visible without depending on the transient
25
+ * `[route] …` console notice, which intentionally stays silent for routine
26
+ * unescalated `standard`-tier turns. */
27
+ routedTier?: "trivial" | "standard" | "complex";
20
28
  branch?: string;
21
29
  /** Uncommitted-change count for the `?N` dirty flag; omit/0 = clean. */
22
30
  dirtyCount?: number;
@@ -81,6 +89,7 @@ export function renderStatusBar(d: StatusBarData): string {
81
89
  // Left identity segment (plain text; painted as one bg block at the end).
82
90
  const bits: string[] = [];
83
91
  let modelBit = `${unicode ? "⬢" : "*"} ${d.model}`;
92
+ if (d.routedTier) modelBit += ` ${unicode ? "⚡" : "~"}${d.routedTier}`;
84
93
  if (d.thinking) modelBit += ` · ${unicode ? "◔" : "@"} ${d.thinking}`;
85
94
  bits.push(modelBit);
86
95
  if (d.branch) {