jeo-code 0.8.21 → 0.8.23

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/src/cli/runner.ts CHANGED
@@ -119,11 +119,23 @@ export const COMMANDS: readonly CommandSpec[] = [
119
119
  return args => m.runDaemonCommand(args);
120
120
  },
121
121
  },
122
+ {
123
+ name: "routine",
124
+ summary: "Generate a GitHub Actions workflow that runs jeo headlessly on a schedule or repo event.",
125
+ usage: 'routine init --trigger <schedule|issues|pull_request> --prompt "<task>" [--cron <expr>] [--name <name>] [--out <path>] [--no-pr] [--force] [--dry-run] [--json]',
126
+ loader: async () => {
127
+ // Dynamic import: lazy-loaded command registry entry (CommandSpec.loader
128
+ // contract), matching every other entry in this array — avoids eagerly
129
+ // importing all ~25 command modules at CLI startup.
130
+ const m = await import("../commands/routine");
131
+ return args => m.runRoutineCommand(args);
132
+ },
133
+ },
122
134
 
123
135
  {
124
136
  name: "skills",
125
137
  summary: "List bundled workflow skills, or sync them into ~/.jeo/skills (jeo skills <name> for details).",
126
- usage: "skills [list|read <name>|sync [--check|--force] [dir]|--write [dir]] [--json]",
138
+ usage: "skills [list|read <name>|sync [--check|--force] [dir]|--write [dir]|lesson <skill> <failure|anti-pattern> \"<title>\" \"<detail>\"|eval <skill>] [--json]",
127
139
  loader: async () => {
128
140
  const m = await import("../commands/skills");
129
141
  return args => m.runSkillsCommand(args);
@@ -8,6 +8,7 @@ import { executeComputerAction } from "./computer";
8
8
  import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
9
9
  import { initialDynamicStepLimit } from "../agent/step-budget";
10
10
  import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
11
+ import { recordSkillLesson } from "../agent/skill-lessons";
11
12
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
12
13
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
13
14
  import { SubagentRegistry } from "../agent/subagent-registry";
@@ -51,7 +52,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
51
52
  import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
52
53
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
53
54
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
54
- import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
55
+ import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, credentialScopeFor, resolveVerifierModel, type PromptTier, type RouteDecision, type RoutingConfig } from "../agent/prompt-router";
55
56
  import { RouteHistory } from "../agent/route-history";
56
57
 
57
58
  import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
@@ -114,7 +115,9 @@ import {
114
115
  appendCompaction,
115
116
  resolveSessionRef,
116
117
  } from "../agent/session";
117
- import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard } from "../tui/terminal";
118
+ import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard, setCursorColor, resetCursorColor, isTTY, watchResize } from "../tui/terminal";
119
+
120
+
118
121
 
119
122
 
120
123
  import {
@@ -196,6 +199,8 @@ import {
196
199
  contextUsageLines,
197
200
  historyViewLines,
198
201
  } from "./launch/slash-views";
202
+ import { formatTranscript } from "../tui/components/transcript";
203
+
199
204
 
200
205
  import {
201
206
  handleUsage,
@@ -761,17 +766,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
761
766
 
762
767
  // pi-style session persistence: resume an existing session or create a new one.
763
768
  let sessionId: string | undefined;
769
+ // True once a resume actually restored PRIOR messages (not just an empty
770
+ // placeholder). Consumed by the "INTERACTIVE mode" welcome-banner block below to
771
+ // skip the fresh-start clearScreen()+banner render — found live: printing the
772
+ // resumed transcript ledger here, then unconditionally wiping the screen and
773
+ // drawing the generic welcome box a few hundred lines later, buried the actual
774
+ // restored work under a "fresh start" banner instead of leaving it on screen.
775
+ let resumedWithHistory = false;
776
+
764
777
  let compactionSeq = 0;
765
778
  if (!flags.noSession) {
766
779
  if (flags.resumeInteractive) {
780
+
767
781
  // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): the interactive
768
782
  // session picker — added right before the REPL loop starts, below — takes over
769
783
  // and replaces this session before the user ever sees it, but ONLY in a TTY.
770
- // Start with a fresh session here as the safe default either way.
771
- sessionId = (await createSession(cwd, undefined, sessionModel)).id;
772
784
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
773
785
  // No TTY means no picker is possible — fall back to the same silent-latest
774
- // behavior as `--continue` rather than silently doing nothing.
786
+ // behavior as `--continue` rather than silently doing nothing. Resolve the
787
+ // TRUE latest session BEFORE creating any placeholder session below — creating
788
+ // one first would make IT the newest-mtime file, permanently masking the real
789
+ // prior session and turning this fallback into dead code (found live: a bare
790
+ // `--resume` outside a TTY always "resumed" a fresh empty session instead of
791
+ // the actual prior one).
775
792
  const id = await latestSessionId(cwd);
776
793
  if (id) {
777
794
  try {
@@ -781,11 +798,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
781
798
  if (!initialSessionModel && header.model) sessionModel = header.model;
782
799
  const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
783
800
  console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
801
+ // Reproduce the prior screen exactly (gjc/gajae-code parity): a bare
802
+ // `--resume` picker with no TTY falls back to the latest session, so
803
+ // this is the ONLY chance to show the prior work before the REPL
804
+ // starts — replay it as a scrollback ledger, same formatter `/session
805
+ // resume` uses mid-session, so a resumed screen always reads like the
806
+ // exact prior conversation instead of a bare message count.
807
+ if (messages.length > 0) {
808
+ resumedWithHistory = true;
809
+ const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
810
+ console.log([sep, ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }), sep].join("\n"));
811
+ }
812
+
813
+
784
814
  } catch (err) {
785
815
  console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
786
816
  }
787
817
  }
818
+ // No prior session to resume (fresh cwd), or the resume attempt above failed —
819
+ // safe-default fresh session, same as every other "nothing to resume" path.
820
+ if (!sessionId) {
821
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
822
+ }
823
+ } else {
824
+ // TTY: start with a fresh session as the safe default; the interactive picker
825
+ // below (right before the REPL loop) takes over and replaces it before the
826
+ // user ever sees this one, so creating it first here is harmless.
827
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
788
828
  }
829
+
789
830
  } else if (flags.resume) {
790
831
  let id: string | undefined;
791
832
  let skipResume = false;
@@ -823,6 +864,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
823
864
  if (header.draft) startupDraft = header.draft;
824
865
  const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
825
866
  console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
867
+ // Reproduce the prior screen exactly (gjc/gajae-code parity): same
868
+ // scrollback-ledger replay `/session resume` uses mid-session, so
869
+ // `--resume`/`-c`/`--resume <id>` restores the prior work onto the
870
+ // screen instead of just a bare message count.
871
+ if (messages.length > 0) {
872
+ resumedWithHistory = true;
873
+ const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
874
+ console.log([sep, ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }), sep].join("\n"));
875
+ }
876
+
877
+
826
878
  } catch (err) {
827
879
  console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
828
880
  sessionId = (await createSession(cwd, undefined, sessionModel)).id;
@@ -925,11 +977,54 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
925
977
  const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
926
978
  let routeCredentialNotice: string | undefined;
927
979
  let routeVetoNote: string | undefined;
980
+ // Credential SCOPES that have shown an ACCOUNT-LEVEL failure (rate limit,
981
+ // usage/quota limit, or zero usable credential) THIS turn — accumulates across
982
+ // every fallback round. Read by both `equivalentRouteFallback` (post/pre-call
983
+ // fallback search) and `rateLimitFallbackAvailable` (the engine's fast-bail
984
+ // predicate) so neither path proposes a candidate doomed to fail the same way.
985
+ //
986
+ // Scoped to the CREDENTIAL, not the bare provider (see `credentialScopeFor` in
987
+ // prompt-router.ts for the full classification rationale/doc comment) — an OAuth
988
+ // SUBSCRIPTION shares ONE account-wide rate-limit window across every model it
989
+ // serves; an API key is typically its OWN independent per-key/per-model budget.
990
+ // `null` (from `credentialScopeFor`) means "API-key-served, or otherwise not
991
+ // sharing a subscription credential" — never added to or matched against this set.
992
+ const excludedCredentialScopes = new Set<string>();
993
+ // Matches routeFailureReason's post-call reasons ("hit a rate limit", "hit a
994
+ // usage/quota limit", "is unauthenticated for this session", "requires
995
+ // billing/payment on this provider account") AND the pre-call credential gate's
996
+ // "(<provider>) has no usable credential this turn" — every string that means
997
+ // "this CREDENTIAL is exhausted/broken", as opposed to a model-specific issue
998
+ // (OAuth serves only some models, a single model is retired/gated — see
999
+ // routeFailureReason's own doc comments for why those stay model-scoped).
1000
+ //
1001
+ // Billing/payment (402) is intentionally included even though the ORIGINAL field
1002
+ // observation behind routeFailureReason's 402 branch (Tencent: kimi-k2.5/
1003
+ // deepseek-v3.2 402 while deepseek-v4-flash/glm-5.2 kept working on the SAME key)
1004
+ // is a per-MODEL billing block on an API-KEY-served provider — this does NOT
1005
+ // contradict including it here: `credentialScopeFor` already returns `null` for
1006
+ // every API-key-served model (never group-excluded regardless of this regex's
1007
+ // answer), so the Tencent case is unaffected either way. What this DOES newly
1008
+ // catch: an OAuth SUBSCRIPTION billing failure (e.g. a Claude Pro/Max payment
1009
+ // lapse) is account-wide by construction — every model riding that subscription
1010
+ // is equally blocked — and previously wasted a full fallback round retrying a
1011
+ // same-scope sibling model guaranteed to 402 identically before ever reaching a
1012
+ // genuinely different credential.
1013
+ const isAccountLevelRouteFailure = (reason: string): boolean =>
1014
+ /hit a rate limit|hit a usage\/quota limit|is unauthenticated for this session|has no usable credential|requires billing\/payment on this provider account/i.test(reason);
928
1015
  const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
1016
+ if (isAccountLevelRouteFailure(reason)) {
1017
+ const scope = credentialScopeFor(decision.model, turnConfig);
1018
+ if (scope) excludedCredentialScopes.add(scope.key);
1019
+ }
929
1020
  const excluded = new Set([decision.model, ...exclude]);
930
1021
  const poolCandidates = (): string[] =>
931
1022
  tierModelPool(decision.tier, turnConfig)
932
1023
  .filter(model => !excluded.has(model))
1024
+ .filter(model => {
1025
+ const scope = credentialScopeFor(model, turnConfig);
1026
+ return !scope || !excludedCredentialScopes.has(scope.key);
1027
+ })
933
1028
  .filter(model => !images?.length || catalogMetadata(model)?.images !== false);
934
1029
  let candidates = poolCandidates();
935
1030
  // The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
@@ -1332,7 +1427,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1332
1427
  }
1333
1428
 
1334
1429
  if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
1335
- const llmVerdict = await verifyGoal(goalState.condition, history, activeModel);
1430
+ const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
1336
1431
  // Deterministic downgrade: a fresh MET from the transcript-only LLM
1337
1432
  // judge still needs mutation/verification evidence to back it up —
1338
1433
  // see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
@@ -1397,6 +1492,57 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1397
1492
  tags: ["jeo", "launch"],
1398
1493
  });
1399
1494
  opik.startTurn();
1495
+ // Hoisted above `runLoopWithModel` (moved from after its first call) so the
1496
+ // rate-limit fast-fallback predicate below can see the CURRENT attempted-model
1497
+ // set on every call, including the very first one. `attemptedRouteModels` is
1498
+ // mutated in place (`.add`) as the fallback loop below tries more models — this
1499
+ // closure always reads its live contents, never a stale snapshot.
1500
+ const attemptedRouteModels = new Set<string>([activeModel]);
1501
+ // A NON-routed turn (routing disabled, or the user pinned a model via /model)
1502
+ // still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
1503
+ // failure — the exact "5-hour usage window exhausted, model just stops working"
1504
+ // case this loop exists to fix. Synthesize a same-size-class RouteDecision
1505
+ // (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
1506
+ // with routing off; a real `routed` decision (routing enabled) is used unchanged.
1507
+ let fallbackBaseDecision: RouteDecision = routed ?? {
1508
+ model: activeModel,
1509
+ thinking: activeThinking,
1510
+ tier: inferTierForModel(activeModel),
1511
+ confidence: 1,
1512
+ source: "heuristic",
1513
+ signals: [],
1514
+ };
1515
+ // Cheap SYNC check (no network/credential probing — that full validation is
1516
+ // `equivalentRouteFallback`'s job when a switch actually happens) for "does an
1517
+ // untried, DIFFERENT-PROVIDER candidate exist RIGHT NOW". Feeds
1518
+ // AgentLoopOptions.rateLimitFallbackAvailable so the engine can bail a 429 retry
1519
+ // ladder on the first failed attempt instead of riding ~90s of backoff on a
1520
+ // model that has an equivalent sitting right there. Mirrors
1521
+ // `equivalentRouteFallback`'s own `poolCandidates` filters (tier pool minus
1522
+ // already-tried models minus image-incapable models when this turn has images)
1523
+ // PLUS excludes any candidate sharing the SAME CREDENTIAL SCOPE as the model
1524
+ // CURRENTLY being attempted (a 429 on this model means ITS credential's
1525
+ // account/subscription window is exhausted THIS instant — another model
1526
+ // riding the SAME OAuth subscription hits that exact limit and is not a real
1527
+ // fallback; an API-key-served candidate has an independent budget and is NOT
1528
+ // excluded) and any scope already excluded by an earlier account-level
1529
+ // failure this turn — see `excludedCredentialScopes`'s doc comment. Without
1530
+ // this, the predicate could see e.g. `claude-sonnet-4-6` as "available" while
1531
+ // `claude-sonnet-5` (same Anthropic OAuth subscription) is the one currently
1532
+ // 429ing, bail, switch to it, and immediately re-429 — which looks
1533
+ // indistinguishable from "never escaping auto-retry #1".
1534
+ const rateLimitFallbackAvailable = (): boolean => {
1535
+ const currentScope = credentialScopeFor(fallbackBaseDecision.model, turnConfig);
1536
+ return tierModelPool(fallbackBaseDecision.tier, turnConfig)
1537
+ .filter(model => !attemptedRouteModels.has(model))
1538
+ .filter(model => {
1539
+ const scope = credentialScopeFor(model, turnConfig);
1540
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
1541
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
1542
+ return !excludedCredentialScopes.has(scope.key);
1543
+ })
1544
+ .some(model => !images?.length || catalogMetadata(model)?.images !== false);
1545
+ };
1400
1546
  const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
1401
1547
  cwd,
1402
1548
  tools,
@@ -1408,6 +1554,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1408
1554
  signal: ac.signal,
1409
1555
  sessionKey: cacheKey,
1410
1556
  steer: drainSteer,
1557
+ rateLimitFallbackAvailable,
1411
1558
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1412
1559
  });
1413
1560
  const routeFailureReason = (doneReason: unknown): string | null => {
@@ -1479,21 +1626,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1479
1626
  const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
1480
1627
  ? Math.floor(routeFallbackMaxRaw)
1481
1628
  : 3;
1482
- const attemptedRouteModels = new Set<string>([activeModel]);
1483
- // A NON-routed turn (routing disabled, or the user pinned a model via /model)
1484
- // still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
1485
- // failure — the exact "5-hour usage window exhausted, model just stops working"
1486
- // case this loop exists to fix. Synthesize a same-size-class RouteDecision
1487
- // (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
1488
- // with routing off; a real `routed` decision (routing enabled) is used unchanged.
1489
- let fallbackBaseDecision: RouteDecision = routed ?? {
1490
- model: activeModel,
1491
- thinking: activeThinking,
1492
- tier: inferTierForModel(activeModel),
1493
- confidence: 1,
1494
- source: "heuristic",
1495
- signals: [],
1496
- };
1497
1629
  for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
1498
1630
  const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
1499
1631
  if (!routedFailureReason) break;
@@ -1541,6 +1673,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1541
1673
  signal: ac.signal,
1542
1674
  sessionKey: turnSessionKey,
1543
1675
  steer: drainSteer,
1676
+ // Deliberately NOT wiring rateLimitFallbackAvailable here: this repair
1677
+ // retry has no reroute loop around it (unlike runLoopWithModel's
1678
+ // routeFallbackAttempt loop) — bailing the retry ladder early on a 429
1679
+ // would only fail FASTER with the identical outcome (no fallback model to
1680
+ // switch to either way), trading away the one real win the full backoff
1681
+ // ladder still offers here: a transient (<~90s) rate-limit blip clearing
1682
+ // mid-wait and this retry succeeding. See launch.ts's `rateLimitFallbackAvailable`
1683
+ // doc comment / commit history for why this predicate is paired with an
1684
+ // actual model-switch loop everywhere else it's used.
1544
1685
  events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
1545
1686
  });
1546
1687
  const usage =
@@ -1634,12 +1775,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1634
1775
  const tags = Array.from(new Set(
1635
1776
  task.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? [],
1636
1777
  )).slice(0, 8);
1637
- void recordFailedAttempt(cwd, {
1638
- title: `Stalled on: ${excerpt}`,
1639
- description: `A prior turn stalled (${why}) on this task — change approach before retrying.`,
1640
- body: `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
1641
- `Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`,
1642
- tags,
1778
+ const title = `Stalled on: ${excerpt}`;
1779
+ const description = `A prior turn stalled (${why}) on this task — change approach before retrying.`;
1780
+ const body = `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
1781
+ `Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`;
1782
+ void recordFailedAttempt(cwd, { title, description, body, tags }).catch(() => {});
1783
+ // Also offer the same failure to the skill-lessons system (deterministic
1784
+ // free-text -> skill match; writes nothing when no skill clearly relates).
1785
+ void recordSkillLesson(cwd, `${title} ${description}`, {
1786
+ kind: "failure-mode",
1787
+ title,
1788
+ detail: body.slice(0, 300),
1643
1789
  }).catch(() => {});
1644
1790
  }
1645
1791
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
@@ -1914,18 +2060,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1914
2060
  renderWelcome({ ...welcomeData, cols: terminalSize().cols });
1915
2061
  // gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
1916
2062
  // never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
1917
- if (process.stdout.isTTY) process.stdout.write(clearScreen());
1918
- // Launch sweep: the forge mark's gradient loops seamlessly (default 2 full
1919
- // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1920
- // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
1921
- const sweepable =
1922
- !!process.stdout.isTTY &&
1923
- welcomeTheme.color &&
1924
- detectColorLevel(process.env, true) === ColorLevel.TrueColor &&
1925
- jeoEnv("NO_WELCOME_ANIM") !== "1";
1926
- const sweepCycles = Math.min(10, Math.max(1, Number(jeoEnv("WELCOME_ANIM_CYCLES")) || 2));
1927
- if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
1928
- else console.log(renderWelcome(welcomeData).join("\n"));
2063
+ // SKIPPED on a resume that actually restored prior messages: the transcript ledger
2064
+ // was already printed above (during session persistence, right after "Resumed
2065
+ // session ... (N messages)"), so clearing here would wipe it and the generic
2066
+ // "fresh start" banner below would bury the just-restored work — live-verified via
2067
+ // a real pty run of `--resume <id>`, which showed exactly that: the resumed
2068
+ // conversation flashed on screen then vanished under the welcome box a moment later.
2069
+ if (!resumedWithHistory) {
2070
+ if (process.stdout.isTTY) process.stdout.write(clearScreen());
2071
+ // Launch sweep: the forge mark's gradient loops seamlessly (default 2 full
2072
+ // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
2073
+ // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
2074
+ const sweepable =
2075
+ !!process.stdout.isTTY &&
2076
+ welcomeTheme.color &&
2077
+ detectColorLevel(process.env, true) === ColorLevel.TrueColor &&
2078
+ jeoEnv("NO_WELCOME_ANIM") !== "1";
2079
+ const sweepCycles = Math.min(10, Math.max(1, Number(jeoEnv("WELCOME_ANIM_CYCLES")) || 2));
2080
+ if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
2081
+ else console.log(renderWelcome(welcomeData).join("\n"));
2082
+ }
2083
+
2084
+
1929
2085
 
1930
2086
  // Surface the "New version" banner reliably: render ONCE from the on-disk cache
1931
2087
  // instantly (no network wait, works offline — the common path after the first
@@ -2624,11 +2780,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2624
2780
  }
2625
2781
  return out;
2626
2782
  };
2783
+ // Match the REAL terminal cursor's color to the theme accent (OSC 12) so the caret
2784
+ // is never a same-toned block over the input box's typed text — the default terminal
2785
+ // cursor color (usually solid white) otherwise reads as a stray light patch on every
2786
+ // non-mono theme, especially the darker ones, making it hard to spot where typing
2787
+ // lands. Colorless (`mono`) intentionally skips this and any terminal that ignores
2788
+ // OSC 12 (rare) is unaffected either way. Reset on process exit (see below).
2789
+ const applyCursorColor = (): void => {
2790
+ if (isTTY() && uiTheme.color) {
2791
+ try { process.stdout.write(setCursorColor(uiTheme.accent)); } catch { /* terminal gone */ }
2792
+ }
2793
+ };
2627
2794
  const refreshUiTheme = (): void => {
2628
2795
  uiTheme = resolveTheme(process.env);
2629
2796
  uiAccent = accentPaint(uiTheme);
2630
2797
  uiAccentShadow = accentShadowPaint(uiTheme);
2798
+ applyCursorColor();
2631
2799
  };
2800
+ applyCursorColor();
2801
+ process.once("exit", () => {
2802
+ try { process.stdout.write(resetCursorColor()); } catch { /* terminal gone */ }
2803
+ });
2804
+
2632
2805
  // The gjc-layout status bar pinned directly ABOVE the input box: bg-gradient
2633
2806
  // identity block (model · thinking / branch / cwd) left, live ctx% right.
2634
2807
  const statusBarLine = (cols: number): string => {
@@ -2945,7 +3118,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2945
3118
  }, 16);
2946
3119
  };
2947
3120
  process.stdout.on("resize", onPickerResize);
3121
+ // Poll-based safety net (same rationale as the idle footer's watchResize): a
3122
+ // picker left open across a missed 'resize' event would otherwise stay pinned
3123
+ // to its opening geometry until a keypress happened to trigger a repaint.
3124
+ const stopPickerResizeWatch = watchResize(() => onPickerResize());
2948
3125
  try {
3126
+
2949
3127
  await new Promise<void>(resolve => {
2950
3128
  const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; sequence?: string } | undefined) => {
2951
3129
  // Same normalization as the footer handler: kitty CSI-u Esc/Ctrl+C must
@@ -2963,7 +3141,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2963
3141
  });
2964
3142
  } finally {
2965
3143
  process.stdout.off("resize", onPickerResize);
3144
+ stopPickerResizeWatch();
2966
3145
  if (resizeTimer) clearTimeout(resizeTimer);
3146
+
2967
3147
  if (process.stdin.setRawMode && !wasRaw) {
2968
3148
  process.stdin.setRawMode(false);
2969
3149
  }
@@ -3716,12 +3896,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3716
3896
  }, RESIZE_THROTTLE_MS);
3717
3897
  };
3718
3898
  process.stdout.on("resize", idleResizeHandler);
3899
+ // Poll-based safety net: the idle footer sits between turns for most of a
3900
+ // session's wall-clock time, so a missed 'resize' event here (tmux pane switch
3901
+ // while this pane isn't foreground, a SIGCONT race, etc. — see terminal.ts's
3902
+ // watchResize doc) is the single biggest source of "screen stays at its
3903
+ // starting size" reports. Cheap 300ms poll; idleResizeHandler already dedupes
3904
+ // via lastIdleAt/reReserveFooter so this composes safely with the real event.
3905
+ const stopIdleResizeWatch = watchResize(() => idleResizeHandler());
3719
3906
  promptListenerCleanups.push(() => {
3720
3907
  process.stdout.off("resize", idleResizeHandler);
3721
3908
  if (idleResizeTimer) clearTimeout(idleResizeTimer);
3909
+ stopIdleResizeWatch();
3722
3910
  });
3723
3911
  }
3724
3912
 
3913
+
3914
+
3725
3915
  // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): show the SAME
3726
3916
  // interactive session picker `/session resume` (no arg) uses, in place of the
3727
3917
  // fresh session created above. TTY-only — non-TTY already fell back to
@@ -19,6 +19,9 @@ import {
19
19
  resolveSubagentMaxSteps,
20
20
  } from "../agent/subagents";
21
21
  import { runAgentLoop } from "../agent/engine";
22
+ import { inferTierForModel, tierModelPool, credentialScopeFor, selectFromPool } from "../agent/prompt-router";
23
+ import { isRateLimitError } from "../util/retry";
24
+ import { MAX_SUBAGENT_REROUTES } from "../agent/task-tool";
22
25
 
23
26
  /** Round-11 (architect ref 8-Round10Planning #1): the REAL consensus gate. A
24
27
  * read-only critic SUBAGENT (repo access via read/search/find) reviews the
@@ -33,7 +36,7 @@ export async function runConsensusCriticGate(args: {
33
36
  }): Promise<{ verdict: "okay" | "iterate" | "reject" | "unverified"; detail: string }> {
34
37
  const role = getSubagentRole("critic")!;
35
38
  const config = await readGlobalConfig();
36
- const model = resolveSubagentModel(role.id, config);
39
+ const initialModel = resolveSubagentModel(role.id, config);
37
40
  const maxSteps = resolveSubagentMaxSteps(role.id, config);
38
41
  const history: Message[] = [
39
42
  { role: "system", content: subagentSystemPrompt(role) },
@@ -48,14 +51,48 @@ export async function runConsensusCriticGate(args: {
48
51
  `Then call done — your reason MUST start with [OKAY], [ITERATE], or [REJECT] and include a 'Justification:' section.`,
49
52
  },
50
53
  ];
51
- const result = await runAgentLoop(history, {
54
+ // Rate-limit fast fallback (see task-tool.ts's `runSubagentOnce` for the full
55
+ // design doc — same pattern, single-call variant: no fan-out here, so no
56
+ // batch-shared excludedCredentialScopes need apply). A bail-only predicate with
57
+ // no real reroute-and-retry loop around it is a net regression (02b7e59) — this
58
+ // gate pairs `rateLimitFallbackAvailable` with the actual switch-and-retry below.
59
+ const tier = inferTierForModel(initialModel);
60
+ const attemptedModels = new Set<string>([initialModel]);
61
+ const excludedCredentialScopes = new Set<string>();
62
+ const fallbackCandidates = (): string[] => {
63
+ const currentScope = credentialScopeFor(activeModel, config);
64
+ return tierModelPool(tier, config)
65
+ .filter(m => !attemptedModels.has(m))
66
+ .filter(m => {
67
+ const scope = credentialScopeFor(m, config);
68
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
69
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
70
+ return !excludedCredentialScopes.has(scope.key);
71
+ });
72
+ };
73
+ const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
74
+
75
+ let activeModel = initialModel;
76
+ const runOnce = () => runAgentLoop(history, {
52
77
  cwd: args.cwd,
53
- model,
78
+ model: activeModel,
54
79
  maxSteps,
55
80
  budget: { maxExtensions: 0 },
56
81
  signal: args.signal,
57
82
  tools: subagentToolset(role),
83
+ rateLimitFallbackAvailable,
58
84
  });
85
+ let result = await runOnce();
86
+ for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
87
+ if (result.done || !isRateLimitError(new Error(result.doneReason ?? ""))) break;
88
+ const failedScope = credentialScopeFor(activeModel, config);
89
+ if (failedScope) excludedCredentialScopes.add(failedScope.key);
90
+ const candidates = fallbackCandidates();
91
+ if (candidates.length === 0) break;
92
+ activeModel = selectFromPool(candidates, undefined);
93
+ attemptedModels.add(activeModel);
94
+ result = await runOnce();
95
+ }
59
96
  const reason = result.doneReason?.trim() ?? "";
60
97
  if (!result.done) return { verdict: "unverified", detail: reason || `critic did not converge within ${result.steps} steps` };
61
98
  const contract = validateSubagentDoneReason(role, reason);