one-code-extension 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -202
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -1,29 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The classifier call itself — the one file here that talks to a provider.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Claude Code's two-stage formula, reproduced (docs/decisions/auto-mode.md, P4):
|
|
5
|
+
* Stage 1 grades HARM ONLY (maxTokens 64). severity < 50 → allow, no stage 2.
|
|
6
|
+
* Stage 2 applies intent + ALLOW (maxTokens 8192, <thinking> CoT) → severity +
|
|
7
|
+
* <category> (+ our verified <intent>). Both stages share one system prompt and
|
|
8
|
+
* transcript byte-for-byte. Only the system prompt (the ~30k-token ruleset) is
|
|
9
|
+
* a cache hit across stages and calls: pi-ai marks a breakpoint on the last
|
|
10
|
+
* user block only, so the <transcript> (up to ~15k tokens) is re-read uncached
|
|
11
|
+
* by stage 2 and by every gated call on Anthropic-style providers. Known,
|
|
12
|
+
* deferred — docs/decisions/caching.md "Classifier transcript" and
|
|
13
|
+
* docs/upstream_prs.md #16 (fixable locally via pi-ai's `onPayload`).
|
|
7
14
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* A one-shot `completeSimple` per stage rather than an agent session: no tools,
|
|
16
|
+
* no history beyond the transcript it is handed, nothing to be talked into. Every
|
|
17
|
+
* failure path — no model, no credentials, timeout, provider error, truncated or
|
|
18
|
+
* unparseable reply — returns a block; a gate that cannot reach its classifier
|
|
19
|
+
* has approved nothing.
|
|
11
20
|
*/
|
|
12
21
|
|
|
13
|
-
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
22
|
+
import type { Model, Api, AssistantMessage, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
14
23
|
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
15
24
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { forcedReasoningLevel, isReasoningMandatoryError, reasoningRetryLevel } from "../lib/model-policy.ts";
|
|
16
26
|
import type { AutoModeConfig } from "./config.ts";
|
|
17
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
buildPayload,
|
|
29
|
+
type ClassifyRequest,
|
|
30
|
+
type ClassifyVerdict,
|
|
31
|
+
parseSeverity,
|
|
32
|
+
parseStage2,
|
|
33
|
+
reviewUser,
|
|
34
|
+
SEVERITY_THRESHOLD,
|
|
35
|
+
stage1User,
|
|
36
|
+
stage2User,
|
|
37
|
+
} from "./prompt.ts";
|
|
18
38
|
import {
|
|
19
39
|
type Candidate,
|
|
40
|
+
type ClassifierNotice,
|
|
20
41
|
classifierCandidates,
|
|
21
42
|
describeCandidate,
|
|
22
43
|
isModelUnavailableError,
|
|
44
|
+
replyText,
|
|
45
|
+
withAuthBaseUrl,
|
|
23
46
|
} from "./model-select.ts";
|
|
24
|
-
import { indexRules } from "./rules.ts";
|
|
25
47
|
|
|
26
|
-
/** A slow classifier stalls every tool call, so the wait is capped. */
|
|
48
|
+
/** A slow classifier stalls every tool call, so the wait is capped per stage. */
|
|
27
49
|
export const CLASSIFIER_TIMEOUT_MS = 30_000;
|
|
28
50
|
|
|
29
51
|
/**
|
|
@@ -41,9 +63,6 @@ export const ROTATE_AFTER_TIMEOUTS = 2;
|
|
|
41
63
|
* (`deps.signal?.aborted`) before treating an abort as a timeout.
|
|
42
64
|
*/
|
|
43
65
|
export function isClassifierTimeout(message: string): boolean {
|
|
44
|
-
// `\babort` (no trailing boundary) so "abort", "aborted", and "aborting" all
|
|
45
|
-
// match — the observed message is "Request aborted", which a trailing \b
|
|
46
|
-
// would miss. The others are whole words.
|
|
47
66
|
return /\babort|\btimed?\s*out\b|\btimeout\b|\betimedout\b|\bdeadline\b/i.test(message);
|
|
48
67
|
}
|
|
49
68
|
|
|
@@ -61,14 +80,32 @@ export interface ClassifierState {
|
|
|
61
80
|
notified: Set<string>;
|
|
62
81
|
/**
|
|
63
82
|
* Consecutive calls the pinned model has timed out on. Reset by any success;
|
|
64
|
-
* at ROTATE_AFTER_TIMEOUTS the pin is released so the session re-picks
|
|
65
|
-
* than staying bound to a model that has fallen behind demand.
|
|
83
|
+
* at ROTATE_AFTER_TIMEOUTS the pin is released so the session re-picks.
|
|
66
84
|
*/
|
|
67
85
|
timeoutStreak: number;
|
|
86
|
+
/**
|
|
87
|
+
* The candidate chain cached for the current (session model, classifier config)
|
|
88
|
+
* signature, so the O(catalog) ranking is not rebuilt on every gated tool call.
|
|
89
|
+
* Invalidated only when the session model or the `classifierModel` setting
|
|
90
|
+
* changes — catalog churn is deliberately ignored, matching the pin, which
|
|
91
|
+
* already commits the session to one classifier. The live `rejected` filter is
|
|
92
|
+
* still applied to the cached chain per call, so a model that dies mid-session
|
|
93
|
+
* is still stepped over without a rebuild.
|
|
94
|
+
*/
|
|
95
|
+
chainCache?: { signature: string; candidates: Candidate[]; notices: ClassifierNotice[] };
|
|
96
|
+
/**
|
|
97
|
+
* `provider/id` -> the thinking level a model turned out to REQUIRE (its
|
|
98
|
+
* provider rejected the thinking-off request with "reasoning is mandatory").
|
|
99
|
+
* Learned once per model via a single retry, then sent on every later call so
|
|
100
|
+
* the failed round-trip is never paid again. Only needed when the model's
|
|
101
|
+
* catalog entry lacks `thinkingLevelMap.off: null` (that case is handled
|
|
102
|
+
* proactively, without an error).
|
|
103
|
+
*/
|
|
104
|
+
forcedReasoning: Map<string, ThinkingLevel>;
|
|
68
105
|
}
|
|
69
106
|
|
|
70
107
|
export function createClassifierState(): ClassifierState {
|
|
71
|
-
return { rejected: new Set(), notified: new Set(), timeoutStreak: 0 };
|
|
108
|
+
return { rejected: new Set(), notified: new Set(), timeoutStreak: 0, forcedReasoning: new Map() };
|
|
72
109
|
}
|
|
73
110
|
|
|
74
111
|
export interface ClassifierDeps {
|
|
@@ -79,23 +116,66 @@ export interface ClassifierDeps {
|
|
|
79
116
|
state: ClassifierState;
|
|
80
117
|
/** Tell the user something once — which model is in use, or that theirs is dead. */
|
|
81
118
|
onNotice?: (message: string, level: "info" | "warning") => void;
|
|
119
|
+
/** Report each classifier reply's usage, for the all-in footer cost. Observer
|
|
120
|
+
* only — it never affects the payload sent or the verdict parsed. */
|
|
121
|
+
onUsage?: (usage: unknown) => void;
|
|
122
|
+
/**
|
|
123
|
+
* A candidate the provider refused as not usable on this account (stepped
|
|
124
|
+
* over and recorded in `state.rejected`). The wiring republishes it on
|
|
125
|
+
* `lib/model-unusable.ts`'s channel so subagent selection — which shares this
|
|
126
|
+
* classifier's floor and therefore its first pick — skips it too.
|
|
127
|
+
*/
|
|
128
|
+
onModelUnusable?: (model: string, reason: string) => void;
|
|
129
|
+
/**
|
|
130
|
+
* Completed-run subagent review: evaluate the sequence with a single stage-2
|
|
131
|
+
* style full-eval call instead of the two-stage gate (there is no harm floor
|
|
132
|
+
* to short-circuit, and the whole point is the intent/ALLOW judgment).
|
|
133
|
+
*/
|
|
134
|
+
reviewOnly?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** How a single candidate's attempt failed, so the outer loop can react. */
|
|
138
|
+
type StepKind = "timeout" | "cancelled" | "unavailable" | "error" | "truncated";
|
|
139
|
+
class StepError extends Error {
|
|
140
|
+
constructor(
|
|
141
|
+
readonly kind: StepKind,
|
|
142
|
+
message: string,
|
|
143
|
+
) {
|
|
144
|
+
super(message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Signature that must stay equal for the cached chain to be reused (see ClassifierState.chainCache). */
|
|
149
|
+
function selectionSignature(deps: ClassifierDeps): string {
|
|
150
|
+
const session = deps.sessionModel ? `${deps.sessionModel.provider}/${deps.sessionModel.id}` : "(none)";
|
|
151
|
+
return `${session}|${deps.config.classifierModel ?? ""}|${deps.config.classifierModelSetFor ?? ""}`;
|
|
82
152
|
}
|
|
83
153
|
|
|
84
|
-
/** The candidate chain
|
|
85
|
-
function remainingCandidates(deps: ClassifierDeps): Candidate[] {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
154
|
+
/** The candidate chain (minus anything already unusable this session) and its notices. */
|
|
155
|
+
function remainingCandidates(deps: ClassifierDeps): { candidates: Candidate[]; notices: ClassifierNotice[] } {
|
|
156
|
+
const signature = selectionSignature(deps);
|
|
157
|
+
let cached = deps.state.chainCache;
|
|
158
|
+
if (!cached || cached.signature !== signature) {
|
|
159
|
+
const built = classifierCandidates({
|
|
160
|
+
available: deps.registry.getAvailable(),
|
|
161
|
+
sessionModel: deps.sessionModel,
|
|
162
|
+
configured: deps.config.classifierModel,
|
|
163
|
+
configuredSetForContainment: deps.config.classifierModelSetFor,
|
|
164
|
+
});
|
|
165
|
+
cached = { signature, candidates: built.candidates, notices: built.notices };
|
|
166
|
+
// Don't poison the cache with an empty chain (e.g. a not-yet-populated
|
|
167
|
+
// registry) — that would permanently block the gate; recompute next call.
|
|
168
|
+
if (built.candidates.length > 0) deps.state.chainCache = cached;
|
|
169
|
+
}
|
|
170
|
+
const { candidates: all, notices } = cached;
|
|
91
171
|
const usable = all.filter((entry) => !deps.state.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
92
|
-
if (usable.length > 0) return usable;
|
|
172
|
+
if (usable.length > 0) return { candidates: usable, notices };
|
|
93
173
|
// If everything has been rejected, the session model is still worth one more
|
|
94
174
|
// attempt: a gate that stops asking has stopped gating. It has to be the
|
|
95
|
-
// session model specifically — the *last* chain entry is the cost-ranked
|
|
96
|
-
//
|
|
175
|
+
// session model specifically — the *last* chain entry is the cost-ranked pick,
|
|
176
|
+
// which nothing price-chosen should lead, let alone be the sole retry.
|
|
97
177
|
const session = all.filter((entry) => entry.source === "session");
|
|
98
|
-
return session.length > 0 ? session : all.slice(-1);
|
|
178
|
+
return { candidates: session.length > 0 ? session : all.slice(-1), notices };
|
|
99
179
|
}
|
|
100
180
|
|
|
101
181
|
function notifyOnce(deps: ClassifierDeps, key: string, message: string, level: "info" | "warning") {
|
|
@@ -105,36 +185,35 @@ function notifyOnce(deps: ClassifierDeps, key: string, message: string, level: "
|
|
|
105
185
|
}
|
|
106
186
|
|
|
107
187
|
/**
|
|
108
|
-
* Ask the classifier about one tool call
|
|
109
|
-
*
|
|
110
|
-
* An approval gate that cannot reach its classifier has not approved anything.
|
|
188
|
+
* Ask the classifier about one tool call (or, with `reviewOnly`, one finished
|
|
189
|
+
* subagent run). See the module comment for the two-stage flow.
|
|
111
190
|
*/
|
|
112
191
|
export async function classify(request: ClassifyRequest, deps: ClassifierDeps): Promise<ClassifyVerdict> {
|
|
113
|
-
const candidates = remainingCandidates(deps);
|
|
192
|
+
const { candidates, notices } = remainingCandidates(deps);
|
|
114
193
|
if (candidates.length === 0) {
|
|
115
194
|
return { decision: "block", reason: "No model is available to run the auto-mode classifier.", tier: "unmatched" };
|
|
116
195
|
}
|
|
117
196
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
197
|
+
// Selection notices (a configured model unavailable or overridden as stale, a
|
|
198
|
+
// cross-provider setting honored) — surfaced once each at their own level, keyed
|
|
199
|
+
// by their text so an informational "honored" line is not shown as a warning.
|
|
200
|
+
for (const notice of notices) notifyOnce(deps, notice.text, notice.text, notice.level);
|
|
201
|
+
|
|
202
|
+
// buildPayload builds the ~110KB ruleset once and returns the grounding index
|
|
203
|
+
// derived from it, so the ruleset is not rebuilt/re-parsed a second time here.
|
|
204
|
+
const { system, userPrefix, index } = buildPayload(request);
|
|
205
|
+
const stage1Text = stage1User(userPrefix);
|
|
206
|
+
// A permission rule refused something this turn, so stage 2 gets the extra
|
|
207
|
+
// instruction that the user's intent does not excuse an equivalent-effect
|
|
208
|
+
// retry (prompt.ts STAGE2_DENIAL_ADDENDUM). Stage 1 grades harm only and is
|
|
209
|
+
// left byte-identical to CC's.
|
|
210
|
+
const afterRuleDenial = request.transcript.some((entry) => entry.kind === "denied");
|
|
211
|
+
const debug = process.env.CC_AUTO_MODE_DEBUG;
|
|
133
212
|
|
|
134
213
|
// A model already pinned this session is tried first, so the classifier does
|
|
135
|
-
// not change under the session while the pin is healthy. The rest of the
|
|
136
|
-
//
|
|
137
|
-
//
|
|
214
|
+
// not change under the session while the pin is healthy. The rest of the chain
|
|
215
|
+
// stays behind it: a pinned model can be withdrawn mid-session, and the same
|
|
216
|
+
// call must then step onward rather than fail every call forever.
|
|
138
217
|
const pinned = deps.state.pinned;
|
|
139
218
|
const attempts = pinned
|
|
140
219
|
? [
|
|
@@ -155,234 +234,233 @@ export async function classify(request: ClassifyRequest, deps: ClassifierDeps):
|
|
|
155
234
|
|
|
156
235
|
let lastError = "";
|
|
157
236
|
let sawTimeout = false;
|
|
158
|
-
|
|
237
|
+
let timedOutKey: string | undefined;
|
|
238
|
+
// Walks `attempts` in order; a candidate that turns out to require thinking is
|
|
239
|
+
// re-queued at the front for exactly one retry (see the reasoning-mandatory
|
|
240
|
+
// branch below). The `!== undefined` guard narrows `candidate`, so no cast.
|
|
241
|
+
const queue = [...attempts];
|
|
242
|
+
for (let candidate = queue.shift(); candidate !== undefined; candidate = queue.shift()) {
|
|
159
243
|
const model = candidate.model;
|
|
160
244
|
const key = `${model.provider}/${model.id}`;
|
|
161
245
|
|
|
162
246
|
const auth = await deps.registry.getApiKeyAndHeaders(model);
|
|
163
247
|
if (!auth.ok) {
|
|
248
|
+
// No working credentials for this model: a subagent on it would fail the
|
|
249
|
+
// same way, so it is published like a provider refusal.
|
|
164
250
|
reject(key);
|
|
251
|
+
deps.onModelUnusable?.(key, auth.error);
|
|
165
252
|
lastError = auth.error;
|
|
166
253
|
continue;
|
|
167
254
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
255
|
+
const resolvedModel = withAuthBaseUrl(model, auth);
|
|
256
|
+
|
|
257
|
+
// One provider call. `reasoning`/`temperature` are deliberately omitted: pi
|
|
258
|
+
// turns thinking off entirely when `reasoning` is absent (what a classifier
|
|
259
|
+
// wants), and `temperature` is deprecated/unsupported on several models and
|
|
260
|
+
// fails closed. See docs/decisions/auto-mode.md. The one exception: a model
|
|
261
|
+
// that CANNOT disable thinking (catalog-marked, or learned via the
|
|
262
|
+
// reasoning-mandatory retry below) gets its lowest supported level — the
|
|
263
|
+
// off-request would 400 and the gate would fail closed on every call.
|
|
264
|
+
const reasoning = deps.state.forcedReasoning.get(key) ?? forcedReasoningLevel(model);
|
|
265
|
+
const runCall = (userText: string, maxTokens: number, stage: number) => {
|
|
266
|
+
if (debug === "2") {
|
|
267
|
+
process.stderr.write(
|
|
268
|
+
`\n[auto-mode payload] stage=${stage} model=${key} maxTokens=${maxTokens} systemLen=${system.length} userLen=${userText.length}\n` +
|
|
269
|
+
`===SYSTEM===\n${system}\n===USER===\n${userText}\n===END===\n`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
174
272
|
const timeout = AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS);
|
|
175
273
|
return completeSimple(
|
|
176
|
-
|
|
177
|
-
{ systemPrompt: system, messages: [{ role: "user", content:
|
|
274
|
+
resolvedModel,
|
|
275
|
+
{ systemPrompt: system, messages: [{ role: "user", content: userText, timestamp: Date.now() }] },
|
|
178
276
|
{
|
|
179
277
|
apiKey: auth.apiKey,
|
|
180
278
|
headers: auth.headers,
|
|
181
279
|
env: auth.env,
|
|
182
280
|
signal: deps.signal ? AbortSignal.any([deps.signal, timeout]) : timeout,
|
|
183
|
-
...(process.env.CC_AUTO_MODE_DEBUG === "2"
|
|
184
|
-
? {
|
|
185
|
-
onPayload: (payload: unknown) => {
|
|
186
|
-
const body = payload as { system?: Array<Record<string, unknown>> };
|
|
187
|
-
const blocks = (body.system ?? []).map(
|
|
188
|
-
(b) =>
|
|
189
|
-
`{keys=${Object.keys(b).join(",")} textLen=${String((b as { text?: string }).text ?? "").length} cache=${JSON.stringify((b as { cache_control?: unknown }).cache_control)}}`,
|
|
190
|
-
);
|
|
191
|
-
process.stderr.write(`[auto-mode payload] systemBlocks=${blocks.join(" ")}\n`);
|
|
192
|
-
return undefined;
|
|
193
|
-
},
|
|
194
|
-
}
|
|
195
|
-
: {}),
|
|
196
281
|
maxTokens,
|
|
197
|
-
// `reasoning` is deliberately omitted: pi turns thinking off entirely
|
|
198
|
-
// when it is absent, which is what a classifier wants (fast, cheap,
|
|
199
|
-
// deterministic). Passing even "minimal" enables thinking, and the
|
|
200
|
-
// resulting budget is derived from maxTokens — at 512 that lands under
|
|
201
|
-
// Anthropic's 1024-token floor and every request 400s, which the gate
|
|
202
|
-
// then correctly but uselessly reports as a block.
|
|
203
|
-
//
|
|
204
|
-
// `temperature` is likewise NOT passed. It looks free — a classifier
|
|
205
|
-
// wants determinism — but it is deprecated on Claude Sonnet 5 and
|
|
206
|
-
// unsupported on Opus 4.7+, and pi's compat data still advertises it,
|
|
207
|
-
// so the request 400s and the gate blocks every single call. Several
|
|
208
|
-
// OpenAI reasoning models reject it too. A gate that must work across
|
|
209
|
-
// 38 providers cannot afford an option that fails closed on some of
|
|
210
|
-
// them for a property it does not need.
|
|
211
282
|
cacheRetention: "long",
|
|
283
|
+
...(reasoning ? { reasoning } : {}),
|
|
212
284
|
},
|
|
213
285
|
);
|
|
214
286
|
};
|
|
215
287
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
288
|
+
// Turn one reply into text, or throw a StepError the loop knows how to route.
|
|
289
|
+
// `big` is the retry cap for a length-truncated reply; a still-truncated reply
|
|
290
|
+
// at `big` is its own outcome (not a candidate to step past).
|
|
291
|
+
const call = async (userText: string, base: number, big: number, stage: number): Promise<string> => {
|
|
292
|
+
const inspect = (reply: AssistantMessage): string | "length" => {
|
|
293
|
+
deps.onUsage?.(reply.usage);
|
|
294
|
+
if (deps.signal?.aborted) throw new StepError("cancelled", "cancelled");
|
|
295
|
+
if (reply.stopReason === "error" || reply.stopReason === "aborted") {
|
|
296
|
+
const msg = reply.errorMessage ?? reply.stopReason ?? "provider error";
|
|
297
|
+
if (isClassifierTimeout(msg)) throw new StepError("timeout", msg);
|
|
298
|
+
throw new StepError(isModelUnavailableError(msg) ? "unavailable" : "error", msg);
|
|
299
|
+
}
|
|
300
|
+
if (reply.stopReason === "length") return "length";
|
|
301
|
+
return replyText(reply);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
let reply = await runCall(userText, base, stage);
|
|
305
|
+
let seen: string | "length";
|
|
306
|
+
try {
|
|
307
|
+
seen = inspect(reply);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
// One retry on a timeout, before giving up on this model — a stall under
|
|
310
|
+
// load usually clears on a second try. A user cancel or a substantive
|
|
311
|
+
// error is not retried.
|
|
312
|
+
if (error instanceof StepError && error.kind === "timeout") {
|
|
313
|
+
reply = await runCall(userText, base, stage);
|
|
314
|
+
seen = inspect(reply);
|
|
315
|
+
} else {
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (seen !== "length") return seen;
|
|
320
|
+
// A cut-off reply voids the verdict, so retry once with real headroom.
|
|
321
|
+
reply = await runCall(userText, big, stage);
|
|
322
|
+
const retried = inspect(reply);
|
|
323
|
+
if (retried === "length") throw new StepError("truncated", "verdict truncated at maxTokens");
|
|
324
|
+
return retried;
|
|
222
325
|
};
|
|
223
326
|
|
|
224
|
-
let failure: string | undefined;
|
|
225
327
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
328
|
+
let verdict: ClassifyVerdict;
|
|
329
|
+
let stageInfo: string;
|
|
330
|
+
|
|
331
|
+
if (deps.reviewOnly) {
|
|
332
|
+
// A finished run has no harm floor to short-circuit — go straight to the
|
|
333
|
+
// full stage-2 evaluation, reframed to judge the whole sequence.
|
|
334
|
+
const text = await call(reviewUser(userPrefix), 1024, 4096, 2);
|
|
335
|
+
verdict = parseStage2(text, index, request.userMessages);
|
|
336
|
+
stageInfo = `review sev=${parseSeverity(text) ?? "?"}`;
|
|
337
|
+
} else {
|
|
338
|
+
// STAGE 1 — harm only. maxTokens 64 like CC; if the reply overruns 64
|
|
339
|
+
// without a readable severity, one retry with headroom recovers it.
|
|
340
|
+
const s1 = await call(stage1Text, 64, 1024, 1);
|
|
341
|
+
const sev1 = parseSeverity(s1);
|
|
342
|
+
if (sev1 !== null && sev1 < SEVERITY_THRESHOLD) {
|
|
343
|
+
verdict = { decision: "allow", reason: "", tier: "allow" };
|
|
344
|
+
stageInfo = `s1=${sev1} (cleared, no stage 2)`;
|
|
345
|
+
} else {
|
|
346
|
+
// sev1 >= 50, or unparseable → fall to the fuller stage-2 evaluation.
|
|
347
|
+
// stage2User is built here (not eagerly) so the common allow case
|
|
348
|
+
// does not concatenate the transcript prefix a second time.
|
|
349
|
+
const s2 = await call(stage2User(userPrefix, afterRuleDenial), 1024, 4096, 2);
|
|
350
|
+
verdict = parseStage2(s2, index, request.userMessages);
|
|
351
|
+
stageInfo = `s1=${sev1 ?? "?"} s2=${parseSeverity(s2) ?? "?"}`;
|
|
352
|
+
// Stage 1 flagged the call and stage 2 cleared it without quoting the
|
|
353
|
+
// user's intent or citing any rule. CC allows here (severity rules),
|
|
354
|
+
// so this stays an allow — but tagged, so the decision log and debug
|
|
355
|
+
// trace show a clear that rests on nothing checkable (review P11).
|
|
356
|
+
if (verdict.decision === "allow" && verdict.tier === "allow" && !verdict.ruleId) {
|
|
357
|
+
verdict = { ...verdict, ruleId: "unverified-clear" };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
234
361
|
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
362
|
+
// A verdict came back, so any building timeout streak is over.
|
|
363
|
+
deps.state.timeoutStreak = 0;
|
|
364
|
+
// Pin on the first success, so the classifier cannot change under the
|
|
365
|
+
// session and a working model is not re-litigated per call.
|
|
366
|
+
if (!deps.state.pinned) {
|
|
367
|
+
deps.state.pinned = model;
|
|
368
|
+
notifyOnce(deps, `using:${key}`, `Auto mode is screening calls with ${describeCandidate(candidate)}.`, "info");
|
|
369
|
+
}
|
|
370
|
+
if (debug) {
|
|
371
|
+
process.stderr.write(
|
|
372
|
+
`[auto-mode] ${key} ${request.toolName} → ${verdict.decision}` +
|
|
373
|
+
`${verdict.ruleId ? ` (${verdict.ruleId})` : ""} [${stageInfo}]\n` +
|
|
374
|
+
// An unverified intent quote is printed whole: the next mismatch class
|
|
375
|
+
// (2026-09-05 H4 was wrapping quotation marks) is only visible in the
|
|
376
|
+
// exact text the model produced.
|
|
377
|
+
(verdict.raw ? ` raw: ${verdict.ruleId === "intent-unverified" ? verdict.raw : verdict.raw.slice(0, 300)}\n` : ""),
|
|
378
|
+
);
|
|
246
379
|
}
|
|
380
|
+
return verdict;
|
|
381
|
+
} catch (error) {
|
|
382
|
+
// A non-StepError is an unexpected throw; treat it as a substantive
|
|
383
|
+
// "error" (surface it, don't step past it) — same as StepError("error").
|
|
384
|
+
const kind: StepKind = error instanceof StepError ? error.kind : "error";
|
|
385
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
247
386
|
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// "unreadable response" sent a live debugging session hunting for
|
|
254
|
-
// JSON bugs when the real cause was the output budget.
|
|
255
|
-
if (process.env.CC_AUTO_MODE_DEBUG) {
|
|
256
|
-
const u = reply.usage as { input?: number; output?: number; reasoning?: number } | undefined;
|
|
257
|
-
process.stderr.write(
|
|
258
|
-
`[auto-mode] ${key} ${request.toolName} → verdict truncated at maxTokens ` +
|
|
259
|
-
`(in=${u?.input ?? "?"} out=${u?.output ?? "?"} reasoning=${u?.reasoning ?? 0})\n`,
|
|
260
|
-
);
|
|
261
|
-
}
|
|
387
|
+
if (kind === "cancelled") {
|
|
388
|
+
return { decision: "block", reason: "Auto-mode classification was cancelled.", tier: "unmatched" };
|
|
389
|
+
}
|
|
390
|
+
if (kind === "truncated") {
|
|
391
|
+
if (debug) process.stderr.write(`[auto-mode] ${key} ${request.toolName} → verdict truncated at maxTokens\n`);
|
|
262
392
|
return {
|
|
263
393
|
decision: "block",
|
|
264
394
|
reason:
|
|
265
|
-
"The approval classifier's reply was cut off by its output limit before the verdict completed
|
|
395
|
+
"The approval classifier's reply was cut off by its output limit before the verdict completed. If this keeps happening, pin a stronger classifier model with /auto-mode model.",
|
|
266
396
|
tier: "unmatched",
|
|
267
397
|
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (!deps.state.pinned) {
|
|
282
|
-
deps.state.pinned = model;
|
|
283
|
-
notifyOnce(deps, `using:${key}`, `Auto mode is screening calls with ${describeCandidate(candidate)}.`, "info");
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Allows carry no reason, so without this the permissive direction is
|
|
287
|
-
// invisible — which is the harder one to notice and the more costly one
|
|
288
|
-
// to get wrong. `CC_AUTO_MODE_DEBUG=1` puts the raw reply on stderr.
|
|
289
|
-
if (process.env.CC_AUTO_MODE_DEBUG) {
|
|
290
|
-
const u = reply.usage as
|
|
291
|
-
| {
|
|
292
|
-
input?: number;
|
|
293
|
-
output?: number;
|
|
294
|
-
reasoning?: number;
|
|
295
|
-
cacheRead?: number;
|
|
296
|
-
cacheWrite?: number;
|
|
297
|
-
cost?: unknown;
|
|
298
|
-
}
|
|
299
|
-
| undefined;
|
|
300
|
-
const cost = u?.cost;
|
|
301
|
-
const spend =
|
|
302
|
-
cost && typeof cost === "object"
|
|
303
|
-
? Object.entries(cost as Record<string, unknown>)
|
|
304
|
-
.filter(([, value]) => typeof value === "number" && value > 0)
|
|
305
|
-
.map(([name, value]) => `${name}=$${(value as number).toFixed(6)}`)
|
|
306
|
-
.join(" ")
|
|
307
|
-
: "";
|
|
308
|
-
process.stderr.write(
|
|
309
|
-
`[auto-mode] ${key} ${request.toolName} → ${verdict.decision}${verdict.ruleId ? ` (${verdict.ruleId})` : ""}\n` +
|
|
310
|
-
` usage: in=${u?.input ?? "?"} out=${u?.output ?? "?"} reasoning=${u?.reasoning ?? 0} cacheRead=${u?.cacheRead ?? 0} cacheWrite=${u?.cacheWrite ?? 0} ${spend}\n` +
|
|
311
|
-
` raw: ${text.replace(/\s+/g, " ").slice(0, 400)}\n`,
|
|
398
|
+
}
|
|
399
|
+
if (kind === "error") {
|
|
400
|
+
// The provider refused the thinking-off request outright (metadata gap:
|
|
401
|
+
// no `thinkingLevelMap.off: null` on this catalog entry). Learn the
|
|
402
|
+
// model's floor once and retry the same candidate with it.
|
|
403
|
+
if (!reasoning && isReasoningMandatoryError(lastError)) {
|
|
404
|
+
const level = reasoningRetryLevel(model);
|
|
405
|
+
deps.state.forcedReasoning.set(key, level);
|
|
406
|
+
notifyOnce(
|
|
407
|
+
deps,
|
|
408
|
+
`forced-reasoning:${key}`,
|
|
409
|
+
`${key} cannot run with thinking disabled; the classifier now runs it at ${level} thinking.`,
|
|
410
|
+
"info",
|
|
312
411
|
);
|
|
412
|
+
queue.unshift(candidate);
|
|
413
|
+
continue;
|
|
313
414
|
}
|
|
314
|
-
|
|
415
|
+
// A substantive, non-transient failure that is not "model unusable here":
|
|
416
|
+
// surface it rather than papering over something about to clear.
|
|
417
|
+
return { decision: "block", reason: `Auto-mode classifier could not be reached (${lastError}).`, tier: "unmatched" };
|
|
315
418
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// A timeout (after the one retry above) is transient, so the model is NOT
|
|
329
|
-
// rejected — it may be fine on the next call. Instead we step to the next
|
|
330
|
-
// candidate, which is a different model of the *same or cheaper* price in
|
|
331
|
-
// the same provider (the chain is privacy- and budget-contained upstream).
|
|
332
|
-
// A pinned model that keeps timing out is unpinned so the session re-picks.
|
|
333
|
-
if (isClassifierTimeout(lastError)) {
|
|
334
|
-
sawTimeout = true;
|
|
335
|
-
if (deps.state.pinned && `${deps.state.pinned.provider}/${deps.state.pinned.id}` === key) {
|
|
336
|
-
deps.state.timeoutStreak += 1;
|
|
337
|
-
if (deps.state.timeoutStreak >= ROTATE_AFTER_TIMEOUTS) deps.state.pinned = undefined;
|
|
419
|
+
if (kind === "timeout") {
|
|
420
|
+
// Transient — do NOT reject the model, it may be fine next call. Step to
|
|
421
|
+
// the next candidate; a pinned model that keeps timing out is unpinned.
|
|
422
|
+
sawTimeout = true;
|
|
423
|
+
timedOutKey = key;
|
|
424
|
+
if (deps.state.pinned && `${deps.state.pinned.provider}/${deps.state.pinned.id}` === key) {
|
|
425
|
+
deps.state.timeoutStreak += 1;
|
|
426
|
+
if (deps.state.timeoutStreak >= ROTATE_AFTER_TIMEOUTS) deps.state.pinned = undefined;
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
338
429
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
};
|
|
430
|
+
// unavailable: this model is not usable here — step over it and record it.
|
|
431
|
+
reject(key);
|
|
432
|
+
deps.onModelUnusable?.(key, lastError);
|
|
433
|
+
const isConfigured = candidate.source === "configured";
|
|
434
|
+
notifyOnce(
|
|
435
|
+
deps,
|
|
436
|
+
`rejected:${key}`,
|
|
437
|
+
isConfigured
|
|
438
|
+
? `Auto mode cannot use ${key} from autoMode.classifierModel (${lastError}). Set a different model in ~/.onecode/settings.json.`
|
|
439
|
+
: `Auto mode cannot use ${key} as its classifier (${lastError}); trying another model. Set autoMode.classifierModel in ~/.onecode/settings.json to choose one.`,
|
|
440
|
+
"warning",
|
|
441
|
+
);
|
|
352
442
|
}
|
|
353
|
-
|
|
354
|
-
reject(key);
|
|
355
|
-
const isConfigured = candidate.source === "configured";
|
|
356
|
-
notifyOnce(
|
|
357
|
-
deps,
|
|
358
|
-
`rejected:${key}`,
|
|
359
|
-
isConfigured
|
|
360
|
-
? `Auto mode cannot use ${key} from autoMode.classifierModel (${lastError}). Set a different model in ~/.claude/settings.json.`
|
|
361
|
-
: `Auto mode cannot use ${key} as its classifier (${lastError}); trying another model. Set autoMode.classifierModel in ~/.claude/settings.json to choose one.`,
|
|
362
|
-
"warning",
|
|
363
|
-
);
|
|
364
443
|
}
|
|
365
444
|
|
|
366
|
-
// Every candidate timed out (with a retry each) and none was reachable in
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
// outright when running non-interactively.
|
|
445
|
+
// Every candidate timed out (with a retry each) and none was reachable in time.
|
|
446
|
+
// This is NOT a judgement that the call is unsafe — it was never screened — so
|
|
447
|
+
// it is surfaced as its own tier. The gate turns a timeout into a user prompt
|
|
448
|
+
// where it can, and only blocks outright when running non-interactively.
|
|
371
449
|
if (sawTimeout) {
|
|
450
|
+
const model = timedOutKey ? timedOutKey.split("/").pop() : undefined;
|
|
372
451
|
return {
|
|
373
452
|
decision: "block",
|
|
374
453
|
tier: "timeout",
|
|
375
454
|
reason:
|
|
376
|
-
|
|
377
|
-
"
|
|
455
|
+
`Auto mode could not screen this ${request.toolName} call in time — the approval classifier${model ? ` (${model})` : ""} ` +
|
|
456
|
+
"is temporarily unavailable (timed out), so the call was not judged either way.",
|
|
378
457
|
};
|
|
379
458
|
}
|
|
380
459
|
|
|
381
|
-
// Every candidate was unusable — say which knob fixes it
|
|
382
|
-
// the user with a gate that blocks everything for no stated reason.
|
|
460
|
+
// Every candidate was unusable — say which knob fixes it.
|
|
383
461
|
return {
|
|
384
462
|
decision: "block",
|
|
385
|
-
reason: `No usable auto-mode classifier model (last error: ${lastError}). Set autoMode.classifierModel in ~/.
|
|
463
|
+
reason: `No usable auto-mode classifier model (last error: ${lastError}). Set autoMode.classifierModel in ~/.onecode/settings.json.`,
|
|
386
464
|
tier: "unmatched",
|
|
387
465
|
};
|
|
388
466
|
}
|