jeo-code 0.8.15 → 0.8.17
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 +32 -0
- package/README.ja.md +5 -2
- package/README.ko.md +5 -2
- package/README.md +10 -2
- package/README.zh.md +5 -2
- package/package.json +1 -1
- package/src/agent/prompt-router.ts +46 -5
- package/src/agent/route-history.ts +107 -0
- package/src/agent/subagents.ts +20 -3
- package/src/ai/providers/anthropic-compatible.ts +19 -2
- package/src/ai/providers/errors.ts +34 -0
- package/src/ai/providers/kimi.ts +23 -8
- package/src/ai/providers/openai-compatible.ts +19 -2
- package/src/commands/computer.ts +11 -5
- package/src/commands/launch/computer-slash.ts +61 -0
- package/src/commands/launch/route-slash.ts +10 -6
- package/src/commands/launch.ts +169 -13
- package/src/tui/app.ts +43 -1
- package/src/tui/components/live-model-picker.ts +6 -3
- package/src/tui/components/slash.ts +2 -0
- package/src/util/provider-error.ts +62 -15
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ProviderAdapter, CallOptions, ProviderName } from "../types";
|
|
2
2
|
import type { Credential } from "../../auth";
|
|
3
3
|
import { openaiAdapter } from "./openai";
|
|
4
|
+
import { relabelProviderError } from "./errors";
|
|
5
|
+
import { companyLabel } from "../model-catalog";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Factory for OpenAI-compatible providers (LM Studio, xAI/Grok, …). They all speak
|
|
@@ -14,6 +16,7 @@ const KEYLESS: Credential = { kind: "none", provider: "openai" };
|
|
|
14
16
|
|
|
15
17
|
export function makeOpenAICompatibleAdapter(opts: { name: ProviderName; baseUrl: string; keyless?: boolean; thinkingFormat?: CallOptions["reasoningFormat"] }): ProviderAdapter {
|
|
16
18
|
const prefix = `${opts.name}/`;
|
|
19
|
+
const label = companyLabel(opts.name);
|
|
17
20
|
const prep = (o: CallOptions): CallOptions => ({
|
|
18
21
|
...o,
|
|
19
22
|
model: o.model.startsWith(prefix) ? o.model.slice(prefix.length) : o.model,
|
|
@@ -23,12 +26,26 @@ export function makeOpenAICompatibleAdapter(opts: { name: ProviderName; baseUrl:
|
|
|
23
26
|
reasoningFormat: o.reasoningFormat ?? opts.thinkingFormat,
|
|
24
27
|
});
|
|
25
28
|
const credFor = (c: Credential): Credential => (opts.keyless ? KEYLESS : c);
|
|
29
|
+
// `openaiAdapter` hardcodes "OpenAI" as the provider label on every thrown
|
|
30
|
+
// ProviderHttpError/ProviderStreamError — relabel to the REAL backend (e.g. "Groq",
|
|
31
|
+
// "DeepSeek") so friendlyProviderError/the fallback classifier/the user all see the
|
|
32
|
+
// true account that needs attention (auth, billing, rate limit) instead of OpenAI's.
|
|
26
33
|
return {
|
|
27
34
|
name: opts.name,
|
|
28
35
|
supportsNativeTools: openaiAdapter.supportsNativeTools,
|
|
29
|
-
call: (messages, options, credential) =>
|
|
36
|
+
call: async (messages, options, credential) => {
|
|
37
|
+
try {
|
|
38
|
+
return await openaiAdapter.call(messages, prep(options), credFor(credential));
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw relabelProviderError(err, label);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
30
43
|
async *stream(messages, options, credential) {
|
|
31
|
-
|
|
44
|
+
try {
|
|
45
|
+
yield* openaiAdapter.stream!(messages, prep(options), credFor(credential));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw relabelProviderError(err, label);
|
|
48
|
+
}
|
|
32
49
|
},
|
|
33
50
|
};
|
|
34
51
|
}
|
package/src/commands/computer.ts
CHANGED
|
@@ -53,16 +53,21 @@ async function runCommand(cmd: string[]): Promise<{ success: boolean; stdout: st
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export async function executeComputerAction(action: ComputerAction): Promise<{ success: boolean; output: string; error?: string }> {
|
|
57
|
-
|
|
58
|
-
if (
|
|
56
|
+
export async function executeComputerAction(action: ComputerAction, opts?: { enabledOverride?: boolean }): Promise<{ success: boolean; output: string; error?: string }> {
|
|
57
|
+
let enabled = opts?.enabledOverride;
|
|
58
|
+
if (enabled === undefined) {
|
|
59
|
+
const config = await readGlobalConfig().catch(() => null);
|
|
60
|
+
enabled = !!config?.computer?.enabled;
|
|
61
|
+
}
|
|
62
|
+
if (!enabled) {
|
|
59
63
|
return {
|
|
60
64
|
success: false,
|
|
61
65
|
output: "",
|
|
62
|
-
error: "Computer use is disabled. Enable it by setting 'computer.enabled: true' in ~/.jeo/config.json",
|
|
66
|
+
error: "Computer use is disabled. Enable it by running '/computer on' this session, or setting 'computer.enabled: true' in ~/.jeo/config.json",
|
|
63
67
|
};
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
|
|
66
71
|
// Read-only actions don't need supervisor inputAllowed check
|
|
67
72
|
const isReadOnly = action.action === "screenshot" || action.action === "wait";
|
|
68
73
|
if (!isReadOnly) {
|
|
@@ -247,7 +252,8 @@ export async function executeComputerAction(action: ComputerAction): Promise<{ s
|
|
|
247
252
|
const results: string[] = [];
|
|
248
253
|
for (let i = 0; i < action.actions.length; i++) {
|
|
249
254
|
const subAction = action.actions[i];
|
|
250
|
-
const res = await executeComputerAction(subAction);
|
|
255
|
+
const res = await executeComputerAction(subAction, opts);
|
|
256
|
+
|
|
251
257
|
if (!res.success) {
|
|
252
258
|
const err = `Batch failed at step ${i}: ${res.error}`;
|
|
253
259
|
await logAudit(action, false, err);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/computer` slash-command handler, mirroring `/route`'s extraction pattern
|
|
3
|
+
* (see `route-slash.ts`). Handles `/computer [status|on|off]` — toggles a
|
|
4
|
+
* SESSION-LOCAL override of `config.computer.enabled` (the safety-gated
|
|
5
|
+
* desktop-automation tool executed by `executeComputerAction` in
|
|
6
|
+
* `../computer.ts`), without requiring the user to hand-edit
|
|
7
|
+
* `~/.jeo/config.json`.
|
|
8
|
+
*
|
|
9
|
+
* Session-level toggle only — `sessionComputerOverride` is never persisted
|
|
10
|
+
* (mirrors `/route`'s `sessionRouteOverride`). Turning it on for a session
|
|
11
|
+
* does NOT relax the fail-closed `computerSupervisor` kill-switch/heartbeat
|
|
12
|
+
* gate inside `executeComputerAction` — it only satisfies the separate
|
|
13
|
+
* `config.computer.enabled` config gate that sits in front of it. Both gates
|
|
14
|
+
* must pass for any non-read-only action to actually execute.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ComputerSlashCtx {
|
|
18
|
+
sessionComputerOverride: boolean | undefined;
|
|
19
|
+
/** `turnConfig.computer?.enabled ?? false`, read fresh by the caller. */
|
|
20
|
+
computerConfigEnabled: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ComputerSlashResult {
|
|
24
|
+
/** Present only when changed by "on"/"off". */
|
|
25
|
+
sessionComputerOverride?: boolean | undefined;
|
|
26
|
+
/** Lines to print. */
|
|
27
|
+
lines: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const USAGE = "Usage: /computer [status|on|off]";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Handle `/computer [status|on|off]`. Extracted for the same reason as
|
|
34
|
+
* `/route`: shares REPL-local computer-use state with `runTurn` via an
|
|
35
|
+
* explicit ctx/result object rather than closing over it.
|
|
36
|
+
*/
|
|
37
|
+
export function runComputerSlash(input: string, ctx: ComputerSlashCtx): ComputerSlashResult {
|
|
38
|
+
const rest = input.slice("/computer".length).trim();
|
|
39
|
+
const [sub] = rest.split(/\s+/).filter(Boolean);
|
|
40
|
+
const effective = ctx.sessionComputerOverride ?? ctx.computerConfigEnabled;
|
|
41
|
+
|
|
42
|
+
if (!sub || sub === "status") {
|
|
43
|
+
const lines = [`computer use: ${effective ? "on" : "off"} (this session)`];
|
|
44
|
+
if (!effective) {
|
|
45
|
+
lines.push("note: the 'computer' tool is fail-closed while off — run '/computer on' to enable it for this session, or set 'computer.enabled: true' in ~/.jeo/config.json to enable it by default.");
|
|
46
|
+
} else {
|
|
47
|
+
lines.push("note: the fail-closed kill-switch/heartbeat supervisor still gates every non-read-only action (screenshot/wait are exempt) independently of this toggle.");
|
|
48
|
+
}
|
|
49
|
+
return { lines };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === "on") {
|
|
53
|
+
return { sessionComputerOverride: true, lines: ["computer use: on (this session)"] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (sub === "off") {
|
|
57
|
+
return { sessionComputerOverride: false, lines: ["computer use: off (this session)"] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { lines: [`Unknown /computer subcommand: ${sub}`, USAGE] };
|
|
61
|
+
}
|
|
@@ -75,17 +75,21 @@ export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResu
|
|
|
75
75
|
if (isRealDecision(ctx.lastRouteDecision)) {
|
|
76
76
|
lines.push(`last decision: ${ctx.lastRouteDecision.tier} → ${ctx.lastRouteDecision.model} (${ctx.lastRouteDecision.source}: ${ctx.lastRouteDecision.signals.join(", ") || "none"}, confidence ${ctx.lastRouteDecision.confidence.toFixed(2)})`);
|
|
77
77
|
}
|
|
78
|
-
// A model pin (`--model`/`/model`)
|
|
79
|
-
// this session
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
// A model pin (`--model`/`/model`) blocks routing UNLESS the user explicitly ran
|
|
79
|
+
// `/route on` this session — that explicit toggle wins over a prior pin (see
|
|
80
|
+
// `routeOverridesPin` in launch.ts's `runTurn`) so routing actually evaluates
|
|
81
|
+
// every prompt as requested. Surface the distinction here, since "routing: on"
|
|
82
|
+
// read alone would otherwise be misleading about whether it's actually pinned.
|
|
83
|
+
if (ctx.pinnedModel && ctx.sessionRouteOverride !== true) {
|
|
84
|
+
lines.push(`note: model pinned to '${ctx.pinnedModel}' this session — routing will not evaluate any prompt until the pin is cleared (/model auto) or you run '/route on' to override the pin`);
|
|
85
|
+
} else if (ctx.pinnedModel && ctx.sessionRouteOverride === true) {
|
|
86
|
+
lines.push(`note: model pinned to '${ctx.pinnedModel}', but '/route on' overrides the pin — routing will evaluate every prompt`);
|
|
84
87
|
}
|
|
85
88
|
return { lines };
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
|
|
92
|
+
|
|
89
93
|
if (sub === "on") {
|
|
90
94
|
return { sessionRouteOverride: true, lines: ["routing: on (this session)"] };
|
|
91
95
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { createInterface } from "node:readline/promises";
|
|
|
2
2
|
import { emitKeypressEvents } from "node:readline";
|
|
3
3
|
import { PassThrough } from "node:stream";
|
|
4
4
|
import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, type AgentLoopEvents } from "../agent/engine";
|
|
5
|
+
import { computerSupervisor } from "../agent/computer-supervisor";
|
|
6
|
+
import { executeComputerAction } from "./computer";
|
|
7
|
+
|
|
5
8
|
import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
|
|
6
9
|
import { initialDynamicStepLimit } from "../agent/step-budget";
|
|
7
10
|
import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
|
|
@@ -48,8 +51,10 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
|
|
|
48
51
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
49
52
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
50
53
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
51
|
-
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, type RouteDecision } from "../agent/prompt-router";
|
|
52
|
-
|
|
54
|
+
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
|
|
55
|
+
|
|
56
|
+
import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
|
|
57
|
+
|
|
53
58
|
|
|
54
59
|
import { listAliases } from "../ai/model-registry";
|
|
55
60
|
import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
|
|
@@ -206,6 +211,8 @@ import { handleUndoSlash } from "./launch/git-slash";
|
|
|
206
211
|
import { runSessionSlash } from "./launch/session-slash";
|
|
207
212
|
import { runModelSlash } from "./launch/model-slash";
|
|
208
213
|
import { runRouteSlash } from "./launch/route-slash";
|
|
214
|
+
import { runComputerSlash } from "./launch/computer-slash";
|
|
215
|
+
|
|
209
216
|
import { runAgentsSlash, runRolesSlash, runFastSlash, runThinkingSlash } from "./launch/agents-slash";
|
|
210
217
|
import { wikiRootPromptLine, decideWikiSlash } from "./launch/wiki-slash";
|
|
211
218
|
import { decideThemeSlash, buildConfigPanelLines, runEvolveSimulation } from "./launch/system-slash";
|
|
@@ -652,6 +659,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
652
659
|
let sessionRouteOverride: boolean | undefined;
|
|
653
660
|
// Last routing decision (or the reason none applied) — /route why reads this.
|
|
654
661
|
let lastRouteDecision: RouteDecision | { note: string } | null = null;
|
|
662
|
+
// Computer-use session override: undefined = follow config.computer.enabled, true/false = /computer on|off wins this session.
|
|
663
|
+
let sessionComputerOverride: boolean | undefined;
|
|
664
|
+
|
|
655
665
|
// Cache of live, credential-validated models per provider (refreshed by live pickers).
|
|
656
666
|
let liveModelsCache: ProviderModelsResult[] | null = null;
|
|
657
667
|
const getLiveModels = async (force = false): Promise<ProviderModelsResult[]> => {
|
|
@@ -887,9 +897,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
887
897
|
let routeVetoNote: string | undefined;
|
|
888
898
|
const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
|
|
889
899
|
const excluded = new Set([decision.model, ...exclude]);
|
|
890
|
-
const
|
|
891
|
-
.
|
|
892
|
-
|
|
900
|
+
const poolCandidates = (): string[] =>
|
|
901
|
+
tierModelPool(decision.tier, turnConfig)
|
|
902
|
+
.filter(model => !excluded.has(model))
|
|
903
|
+
.filter(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
904
|
+
let candidates = poolCandidates();
|
|
905
|
+
// The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
|
|
906
|
+
// name (tencent, xai) — every other API-key provider (groq, deepseek, mistral,
|
|
907
|
+
// openrouter, …) only enters `tierModelPool` once its `/models` endpoint has
|
|
908
|
+
// been discovered THIS session (`recordLiveProviderModels`, driven by
|
|
909
|
+
// `getLiveModels`). Interactive sessions warm that cache in the background at
|
|
910
|
+
// startup, but a ONE-SHOT invocation (`jeo -p`/piped input) never calls
|
|
911
|
+
// `getLiveModels()` at all — so a credentialed-but-undiscovered API provider
|
|
912
|
+
// had ZERO fallback candidates even though the user's key works. Warming here
|
|
913
|
+
// is cheap: `getLiveModels()` caches its result (`liveModelsCache`), so this
|
|
914
|
+
// only pays real discovery latency once per cold session, exactly when the
|
|
915
|
+
// primary model already failed and a fallback is genuinely needed.
|
|
916
|
+
if (candidates.length === 0) {
|
|
917
|
+
await getLiveModels().catch(() => []);
|
|
918
|
+
candidates = poolCandidates();
|
|
919
|
+
}
|
|
893
920
|
if (candidates.length === 0) return null;
|
|
894
921
|
const selected = selectFromPool(candidates, sessionId ? `${sessionId}:fallback:${decision.model}:${reason}` : undefined);
|
|
895
922
|
const start = Math.max(0, candidates.indexOf(selected));
|
|
@@ -919,9 +946,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
919
946
|
routeCredentialNotice = warnOnce(noticeKey, `${notice} Switching to equivalent '${fallback}' for this turn.`);
|
|
920
947
|
return { ...decision, model: fallback, warning: decision.warning ? `${decision.warning}; ${routeVetoNote}` : routeVetoNote };
|
|
921
948
|
};
|
|
922
|
-
|
|
949
|
+
// `sessionRouteOverride === true` means the user explicitly ran `/route on` —
|
|
950
|
+
// that explicit request wins over a prior model pin (`sessionModel`, from
|
|
951
|
+
// `--model`/`/model`) so routing actually evaluates every prompt as asked,
|
|
952
|
+
// instead of being silently blocked by a pin the user may have forgotten about.
|
|
953
|
+
// Routing enabled only via config (`routing.enabled: true`, no explicit /route
|
|
954
|
+
// toggle this session) still respects an existing pin, as before.
|
|
955
|
+
const routeOverridesPin = sessionRouteOverride === true;
|
|
956
|
+
let routed = routingEnabled && (routeOverridesPin || !sessionModel)
|
|
923
957
|
? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length, sessionId }).catch(() => null)
|
|
924
958
|
: null;
|
|
959
|
+
|
|
925
960
|
// Fail-open credential gate: `routePrompt` only resolves a tier's model from
|
|
926
961
|
// config (routing.tiers/roles.smol/roles.slow/defaultModel) — it deliberately
|
|
927
962
|
// stays pure/config-only (see its narrowed `Pick<Config,...>` param) and never
|
|
@@ -989,8 +1024,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
989
1024
|
}
|
|
990
1025
|
}
|
|
991
1026
|
}
|
|
992
|
-
lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
993
|
-
// Explicit /model always wins
|
|
1027
|
+
lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && (routeOverridesPin || !sessionModel) ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
1028
|
+
// Explicit /model always wins UNLESS an explicit `/route on` this session opted
|
|
1029
|
+
// back into per-prompt routing (`routeOverridesPin`); otherwise `routed` is only
|
|
1030
|
+
// ever computed when `!sessionModel`.
|
|
1031
|
+
|
|
994
1032
|
let activeModel = routed?.model ?? (sessionModel || defaultModel);
|
|
995
1033
|
let activeThinking = routed?.thinking ?? sessionThinking;
|
|
996
1034
|
// Provider-side prompt caches are keyed PER MODEL — reusing the bare `sessionId`
|
|
@@ -1296,6 +1334,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1296
1334
|
irc: createIrcTool(subagentRegistry),
|
|
1297
1335
|
goal: createGoalTool(),
|
|
1298
1336
|
approve: createApproveTool(),
|
|
1337
|
+
// `/computer on|off` session override wins over `config.computer.enabled`
|
|
1338
|
+
// (see `sessionComputerOverride`/`computer-slash.ts`); undefined = fall
|
|
1339
|
+
// through to DEFAULT_TOOLS' own config-only gate inside executeComputerAction.
|
|
1340
|
+
computer: (a: Record<string, any>) => {
|
|
1341
|
+
computerSupervisor.setKillSwitchLive(true);
|
|
1342
|
+
computerSupervisor.heartbeat();
|
|
1343
|
+
return executeComputerAction(a as any, { enabledOverride: sessionComputerOverride });
|
|
1344
|
+
},
|
|
1345
|
+
|
|
1299
1346
|
};
|
|
1300
1347
|
const tools = filterToolMap(fullTools, Array.from(allowedTools));
|
|
1301
1348
|
// Opik observability (opt-in via JEO_OPIK): one trace per turn, spans per
|
|
@@ -1327,6 +1374,46 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1327
1374
|
const asError = new Error(message);
|
|
1328
1375
|
if (isRateLimitError(asError)) return "hit a rate limit";
|
|
1329
1376
|
if (isUsageLimitError(asError)) return "hit a usage/quota limit";
|
|
1377
|
+
// Auth/credential failures (401/403 rejection, missing/expired credential) are
|
|
1378
|
+
// an operational fault of THIS provider, not the prompt or the model's
|
|
1379
|
+
// capability — the equivalent-pool fallback exists precisely to route around a
|
|
1380
|
+
// provider whose credential just broke (expired OAuth token, revoked key, no
|
|
1381
|
+
// key configured) mid-session, instead of failing the whole turn.
|
|
1382
|
+
if (/rejected the credential|no credential for provider|no usable credential|\b401\b|\b403\b/i.test(message)) {
|
|
1383
|
+
return "is unauthenticated for this session";
|
|
1384
|
+
}
|
|
1385
|
+
// Pre-response connection failures (refused/unresolvable host, local
|
|
1386
|
+
// ollama/lmstudio server down, unreachable custom base URL) — see
|
|
1387
|
+
// isConnectionError's docs. Distinct from a mid-stream drop (transient,
|
|
1388
|
+
// already retried by the engine) or a genuine timeout (handled below).
|
|
1389
|
+
if (isConnectionError(asError) || /could not connect|is unreachable at/i.test(message)) {
|
|
1390
|
+
return "is unreachable";
|
|
1391
|
+
}
|
|
1392
|
+
// A silent/no-response provider (the call ran the full wall-clock timeout
|
|
1393
|
+
// with no usable output) is exactly the "no response" exception the
|
|
1394
|
+
// equivalent-pool fallback exists to route around.
|
|
1395
|
+
if (/did not complete the request within the call timeout|the operation timed out|exceeded the overall deadline/i.test(message)) {
|
|
1396
|
+
return "did not respond in time";
|
|
1397
|
+
}
|
|
1398
|
+
// Billing/payment failures (HTTP 402 — quota exhausted with postpaid billing
|
|
1399
|
+
// off, insufficient credit, free-trial exhausted) are a provider-account
|
|
1400
|
+
// fault, not a prompt/model problem: the SAME account's other models are
|
|
1401
|
+
// often unaffected (observed: Tencent's kimi-k2.5/deepseek-v3.2 402 while
|
|
1402
|
+
// deepseek-v4-flash/glm-5.2 on the SAME key kept working). Reroute exactly
|
|
1403
|
+
// like a rate/usage limit instead of leaving a configured-but-billing-blocked
|
|
1404
|
+
// model dead for the rest of the session.
|
|
1405
|
+
if (/\b402\b|payment required|insufficient credit|billing (?:is )?not enabled|free trial quota/i.test(message)) {
|
|
1406
|
+
return "requires billing/payment on this provider account";
|
|
1407
|
+
}
|
|
1408
|
+
// A persistent 5xx that SURVIVED the model-manager's own retry budget
|
|
1409
|
+
// (withRetry already backed off 500/502/503/504/529 internally — see
|
|
1410
|
+
// defaultRetryable's RETRYABLE_STATUS) means the provider's server itself
|
|
1411
|
+
// is down/overloaded beyond what backoff can recover from THIS session.
|
|
1412
|
+
// Equally a provider-side fault as the classes above, not a prompt issue.
|
|
1413
|
+
if (/\b(?:500|502|503|504|529)\b/.test(message)) {
|
|
1414
|
+
return "hit a persistent server-side error";
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1330
1417
|
if (/does not recognize the requested model|model .*?(?:not found|not available|unavailable|unsupported|retired|gated)|\b(?:400|404)\b/i.test(message)) {
|
|
1331
1418
|
return "is unavailable";
|
|
1332
1419
|
}
|
|
@@ -1351,23 +1438,39 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1351
1438
|
? Math.floor(routeFallbackMaxRaw)
|
|
1352
1439
|
: 3;
|
|
1353
1440
|
const attemptedRouteModels = new Set<string>([activeModel]);
|
|
1354
|
-
|
|
1441
|
+
// A NON-routed turn (routing disabled, or the user pinned a model via /model)
|
|
1442
|
+
// still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
|
|
1443
|
+
// failure — the exact "5-hour usage window exhausted, model just stops working"
|
|
1444
|
+
// case this loop exists to fix. Synthesize a same-size-class RouteDecision
|
|
1445
|
+
// (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
|
|
1446
|
+
// with routing off; a real `routed` decision (routing enabled) is used unchanged.
|
|
1447
|
+
let fallbackBaseDecision: RouteDecision = routed ?? {
|
|
1448
|
+
model: activeModel,
|
|
1449
|
+
thinking: activeThinking,
|
|
1450
|
+
tier: inferTierForModel(activeModel),
|
|
1451
|
+
confidence: 1,
|
|
1452
|
+
source: "heuristic",
|
|
1453
|
+
signals: [],
|
|
1454
|
+
};
|
|
1455
|
+
for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
|
|
1355
1456
|
const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
|
|
1356
1457
|
if (!routedFailureReason) break;
|
|
1357
|
-
const fallback = await equivalentRouteFallback(
|
|
1458
|
+
const fallback = await equivalentRouteFallback(fallbackBaseDecision, routedFailureReason, [...attemptedRouteModels]);
|
|
1358
1459
|
if (!fallback) break;
|
|
1359
1460
|
const previousModel = activeModel;
|
|
1360
1461
|
attemptedRouteModels.add(fallback);
|
|
1361
1462
|
activeModel = fallback;
|
|
1362
|
-
activeThinking =
|
|
1463
|
+
activeThinking = fallbackBaseDecision.thinking ?? sessionThinking;
|
|
1363
1464
|
turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
|
|
1364
1465
|
contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
1365
|
-
|
|
1366
|
-
|
|
1466
|
+
fallbackBaseDecision = { ...fallbackBaseDecision, model: activeModel };
|
|
1467
|
+
lastRouteDecision = { ...fallbackBaseDecision, warning: `model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
|
|
1468
|
+
const notice = `[route] model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
|
|
1367
1469
|
if (tui) tui.events().onNotice?.(notice);
|
|
1368
1470
|
else console.log(notice);
|
|
1369
1471
|
result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
|
|
1370
1472
|
}
|
|
1473
|
+
|
|
1371
1474
|
if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
|
|
1372
1475
|
history.push({
|
|
1373
1476
|
role: "user",
|
|
@@ -2845,6 +2948,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2845
2948
|
color: "default",
|
|
2846
2949
|
},
|
|
2847
2950
|
];
|
|
2951
|
+
for (const tier of PROMPT_TIERS) {
|
|
2952
|
+
const configured = cfg.routing?.tiers?.[tier];
|
|
2953
|
+
if (!configured?.model) continue;
|
|
2954
|
+
assignments.push({
|
|
2955
|
+
role: `routing:${tier}`,
|
|
2956
|
+
label: `ROUTE:${tier}`,
|
|
2957
|
+
model: configured.model,
|
|
2958
|
+
thinking: configured.thinking ?? "auto",
|
|
2959
|
+
color: "default",
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2848
2962
|
for (const roleId of orderedRoleIds) {
|
|
2849
2963
|
const role = byId.get(roleId);
|
|
2850
2964
|
if (!role) continue;
|
|
@@ -2950,9 +3064,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2950
3064
|
title: string,
|
|
2951
3065
|
current: ThinkLevel | undefined,
|
|
2952
3066
|
inheritLabel?: string,
|
|
3067
|
+
supportedLevels?: readonly ThinkLevel[],
|
|
2953
3068
|
): Promise<ThinkLevel | "inherit" | undefined> => {
|
|
2954
3069
|
const levels = buildThinkingLevelChoices(current, {
|
|
2955
3070
|
inheritLabel,
|
|
3071
|
+
levels: supportedLevels,
|
|
2956
3072
|
tokenHint: lvl => `~${Math.round(thinkingMaxTokens(lvl as ThinkLevel) / 1000)}k tokens`,
|
|
2957
3073
|
});
|
|
2958
3074
|
const picked = await pickFromOptions(title, levels);
|
|
@@ -3023,6 +3139,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3023
3139
|
return true;
|
|
3024
3140
|
}
|
|
3025
3141
|
|
|
3142
|
+
if (targetId.startsWith("routing:")) {
|
|
3143
|
+
const tier = targetId.slice("routing:".length) as PromptTier;
|
|
3144
|
+
if (!PROMPT_TIERS.includes(tier)) return false;
|
|
3145
|
+
const meta = catalogMetadata(target);
|
|
3146
|
+
const supportedThinking = meta?.thinking ?? [];
|
|
3147
|
+
const currentRouteThinking = cfgForPick.routing?.tiers?.[tier]?.thinking;
|
|
3148
|
+
let thinking: ThinkLevel | undefined;
|
|
3149
|
+
if (supportedThinking.length > 0) {
|
|
3150
|
+
const level = await pickThinkingLevel(
|
|
3151
|
+
`Reasoning for route ${tier}: ${target}`,
|
|
3152
|
+
currentRouteThinking,
|
|
3153
|
+
undefined,
|
|
3154
|
+
supportedThinking,
|
|
3155
|
+
);
|
|
3156
|
+
if (level === undefined) {
|
|
3157
|
+
console.log("(cancelled)");
|
|
3158
|
+
return true;
|
|
3159
|
+
}
|
|
3160
|
+
thinking = isThinkingLevel(level) ? level : undefined;
|
|
3161
|
+
}
|
|
3162
|
+
await saveConfigPatch(raw => ({
|
|
3163
|
+
routing: withRoutingTierSetting(raw, tier, { model: target, thinking }),
|
|
3164
|
+
}));
|
|
3165
|
+
const { provider } = await describeModel(target);
|
|
3166
|
+
const thinkingLabel = supportedThinking.length > 0 ? ` · thinking ${thinking}` : " · no thinking override (model does not advertise thinking)";
|
|
3167
|
+
console.log(`Prompt route ${tier} set to ${target} (${provider})${thinkingLabel} — routing enabled in ~/.jeo/config.json. Clear any session model pin with /model auto to let routing evaluate prompts.`);
|
|
3168
|
+
return true;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3026
3171
|
const role = getSubagentRole(targetId, cfgForPick);
|
|
3027
3172
|
if (!role) return false;
|
|
3028
3173
|
const currentRoleThinking = resolveSubagentThinking(role.id, cfgForPick) as ThinkLevel | undefined;
|
|
@@ -4295,6 +4440,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4295
4440
|
for (const line of routeResult.lines) console.log(line);
|
|
4296
4441
|
continue;
|
|
4297
4442
|
}
|
|
4443
|
+
if (input.startsWith("/computer") && (input === "/computer" || input[9] === " ")) {
|
|
4444
|
+
const cfgNow = await readGlobalConfig();
|
|
4445
|
+
const computerResult = runComputerSlash(input, {
|
|
4446
|
+
sessionComputerOverride,
|
|
4447
|
+
computerConfigEnabled: !!cfgNow.computer?.enabled,
|
|
4448
|
+
});
|
|
4449
|
+
if (computerResult.sessionComputerOverride !== undefined) sessionComputerOverride = computerResult.sessionComputerOverride;
|
|
4450
|
+
for (const line of computerResult.lines) console.log(line);
|
|
4451
|
+
continue;
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4298
4454
|
if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
|
|
4299
4455
|
for (const line of await handleViewSlash(input, cwd)) console.log(line);
|
|
4300
4456
|
continue;
|
package/src/tui/app.ts
CHANGED
|
@@ -1438,6 +1438,33 @@ export class LaunchTui {
|
|
|
1438
1438
|
* block shows only the most-recent lines, capped at ~30% of the screen height (a
|
|
1439
1439
|
* ceiling guards a tall terminal), so it grows with the stream and shrinks with the
|
|
1440
1440
|
* viewport. Returns [] when there is nothing to show. */
|
|
1441
|
+
/** gjc-parity multi-line parallel-subagent panel: when a fan-out `task` batch has
|
|
1442
|
+
* MORE THAN ONE concurrent slot live, render one status line PER active slot
|
|
1443
|
+
* (most-recently-touched first) instead of collapsing everything into a single
|
|
1444
|
+
* "+N more running" summary line (see `currentActivity()`). A single/detached
|
|
1445
|
+
* subagent still uses the plain status-line path — this panel exists specifically
|
|
1446
|
+
* to make a truly PARALLEL batch visibly read as parallel: every worker's latest
|
|
1447
|
+
* activity is on screen at once, capped so a large fan-out can't blow the frame. */
|
|
1448
|
+
private static readonly SUBAGENT_PANEL_MAX_ROWS = 6;
|
|
1449
|
+
private renderSubagentPanel(cols: number): string[] {
|
|
1450
|
+
if (this.subagentLiveSlots.size <= 1) return [];
|
|
1451
|
+
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1452
|
+
const accent = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
|
|
1453
|
+
const spin = this.theme.color ? accent(this.spinner.current()) : this.spinner.current();
|
|
1454
|
+
const order = [...this.subagentLiveOrder].reverse();
|
|
1455
|
+
const shown = order.slice(0, LaunchTui.SUBAGENT_PANEL_MAX_ROWS);
|
|
1456
|
+
const extra = order.length - shown.length;
|
|
1457
|
+
const wrapW = Math.max(8, cols - 4);
|
|
1458
|
+
const lines = shown.map(slot => {
|
|
1459
|
+
const raw = this.subagentLiveSlots.get(slot) ?? "";
|
|
1460
|
+
return ` ${spin} ${dim(truncateToWidth(raw, wrapW))}`;
|
|
1461
|
+
});
|
|
1462
|
+
const header = dim(`parallel · ${this.subagentLiveSlots.size} running`);
|
|
1463
|
+
const rows = [header, ...lines];
|
|
1464
|
+
if (extra > 0) rows.push(dim(` … +${extra} more running`));
|
|
1465
|
+
return rows;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1441
1468
|
private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number, cacheKey = label): string[] {
|
|
1442
1469
|
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1443
1470
|
if (!text.trim()) return [];
|
|
@@ -1616,6 +1643,14 @@ export class LaunchTui {
|
|
|
1616
1643
|
if (this.runningTool && this.liveToolOutput.trim()) {
|
|
1617
1644
|
tail.push(...this.renderLiveBlock("Output", this.liveToolOutput, cols, rows, 8));
|
|
1618
1645
|
}
|
|
1646
|
+
// Parallel subagent panel (gjc parity): a fan-out `task` batch with more than one
|
|
1647
|
+
// concurrent slot live gets its OWN multi-line block — one row per active worker —
|
|
1648
|
+
// so a genuinely parallel batch reads as parallel instead of a single rotating line.
|
|
1649
|
+
if (this.subagentActive) {
|
|
1650
|
+
tail.push(...this.renderSubagentPanel(cols));
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
|
|
1619
1654
|
|
|
1620
1655
|
// Live status field: unboxed thinking line + compact metrics row. The model's
|
|
1621
1656
|
// streamed activity is uniform across providers via streamingActivity and keeps
|
|
@@ -1778,9 +1813,16 @@ export class LaunchTui {
|
|
|
1778
1813
|
const phase = (this.tickCount * 0.05) % 1;
|
|
1779
1814
|
const grad = themeGradient(this.theme, idx);
|
|
1780
1815
|
const palette = [grad.from, grad.to];
|
|
1781
|
-
|
|
1782
1816
|
if (fit) {
|
|
1783
1817
|
bottom.push("");
|
|
1818
|
+
// Live status field: unboxed thinking line + compact metrics row. The
|
|
1819
|
+
// streamed activity is uniform across providers, with the ⟦esc⟧ cancel hint
|
|
1820
|
+
// right-aligned and no misleading step counter.
|
|
1821
|
+
if (this.subagentActive) {
|
|
1822
|
+
for (const line of this.renderSubagentPanel(innerWidth)) bottom.push(line);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
|
|
1784
1826
|
if (this.turnTitle) {
|
|
1785
1827
|
const arrow = this.unicode ? "▸" : ">";
|
|
1786
1828
|
const titleLine = ` ${arrow} ${this.turnTitle}`;
|
|
@@ -20,9 +20,9 @@ function liveModelHint(model: string, current?: string): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface ModelAssignmentBadge {
|
|
23
|
-
/** Stable target id: "default"
|
|
23
|
+
/** Stable target id: "default", a subagent role id, or "routing:<tier>". */
|
|
24
24
|
role: string;
|
|
25
|
-
/** Short badge text shown beside a matching model (DEFAULT, EXECUTOR, ...). */
|
|
25
|
+
/** Short badge text shown beside a matching model (DEFAULT, EXECUTOR, ROUTE:HIGH, ...). */
|
|
26
26
|
label: string;
|
|
27
27
|
/** Configured model id for this target, qualified (`provider/model`) when possible. */
|
|
28
28
|
model: string;
|
|
@@ -120,6 +120,8 @@ const THINKING_LEVEL_DESCRIPTION: Record<string, string> = {
|
|
|
120
120
|
export interface ThinkingLevelChoiceOptions {
|
|
121
121
|
/** Prepend an "inherit" row (role targets only, which can follow the default). */
|
|
122
122
|
inheritLabel?: string;
|
|
123
|
+
/** Restrict visible reasoning levels to the selected model's supported levels. */
|
|
124
|
+
levels?: readonly string[];
|
|
123
125
|
/** Per-level reasoning-budget hint, e.g. "~32k tokens". Return undefined to omit. */
|
|
124
126
|
tokenHint?: (level: string) => string | undefined;
|
|
125
127
|
}
|
|
@@ -138,7 +140,8 @@ export function buildThinkingLevelChoices(
|
|
|
138
140
|
if (opts.inheritLabel) {
|
|
139
141
|
items.push({ value: "inherit", label: opts.inheritLabel, hint: current === undefined ? "current" : "" });
|
|
140
142
|
}
|
|
141
|
-
|
|
143
|
+
const levels = opts.levels?.length ? THINKING_LEVEL_ORDER.filter(level => opts.levels!.includes(level)) : THINKING_LEVEL_ORDER;
|
|
144
|
+
for (const level of levels) {
|
|
142
145
|
const tokens = opts.tokenHint?.(level);
|
|
143
146
|
const label = `${level} — ${THINKING_LEVEL_DESCRIPTION[level]}${tokens ? ` (${tokens})` : ""}`;
|
|
144
147
|
items.push({ value: level, label, hint: current === level ? "current" : "" });
|
|
@@ -55,6 +55,8 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
55
55
|
{ command: "/usage", usage: "/usage", description: "Show cumulative token usage for this session", group: "system" },
|
|
56
56
|
{ command: "/context", usage: "/context", description: "Show context token usage breakdown", group: "system" },
|
|
57
57
|
{ command: "/tools", usage: "/tools", description: "Show the tools currently visible to the agent", group: "system" },
|
|
58
|
+
{ command: "/computer", usage: "/computer [on|off]", description: "Show/toggle the desktop-automation 'computer' tool (screenshot/click/type/etc) for this session", group: "system" },
|
|
59
|
+
|
|
58
60
|
{ command: "/hotkeys", usage: "/hotkeys", description: "Show keyboard shortcuts", group: "system" },
|
|
59
61
|
{ command: "/theme", usage: "/theme [name]", description: "Show or set the TUI theme (cosmic/matrix/solar/mono)", group: "system" },
|
|
60
62
|
{ command: "/wiki", usage: "/wiki [path|off]", description: "Show or set the global llm-wiki vault root (shared across all sessions)", group: "system" },
|