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/CHANGELOG.md +37 -0
- package/README.ja.md +15 -3
- package/README.ko.md +15 -3
- package/README.md +15 -3
- package/README.zh.md +15 -3
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/browser-tool.ts +32 -3
- package/src/agent/engine.ts +24 -4
- package/src/agent/loop.ts +3 -2
- package/src/agent/prompt-router.ts +115 -19
- package/src/agent/skill-lessons.ts +315 -0
- package/src/agent/subagents.ts +7 -6
- package/src/agent/task-tool.ts +210 -68
- package/src/agent/vision-verify.ts +75 -0
- package/src/ai/model-catalog.ts +61 -7
- package/src/ai/model-manager.ts +11 -2
- package/src/ai/types.ts +4 -2
- package/src/cli/runner.ts +13 -1
- package/src/commands/launch.ts +229 -39
- package/src/commands/ralplan.ts +40 -3
- package/src/commands/routine.ts +249 -0
- package/src/commands/skills.ts +57 -0
- package/src/tui/app.ts +19 -1
- package/src/tui/terminal.ts +89 -0
- package/src/util/provider-error.ts +15 -0
- package/src/util/retry.ts +15 -4
- package/src/util/routine-template.ts +206 -0
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);
|
package/src/commands/launch.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
1918
|
-
//
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
package/src/commands/ralplan.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|