diff --git a/server/cos-runner/index.js b/server/cos-runner/index.js index a0c6a61d86..3e0b52a127 100644 --- a/server/cos-runner/index.js +++ b/server/cos-runner/index.js @@ -24,6 +24,7 @@ import { commandExists } from '../lib/commandExists.js'; import { adoptNpmGlobalBinDir } from '../lib/npmGlobalBin.js'; import { findCommandOnPath } from '../lib/processEnv.js'; import { createCodexStderrFormatter } from '../lib/codexCliOutput.js'; +import { isKnownCliStderrNoise } from '../lib/cliStderrNoise.js'; import { createStreamingAnsiStripper } from '../lib/ansiStrip.js'; import { createStreamJsonParser } from './streamJsonParser.js'; import { loadState, saveState, withState } from './runnerState.js'; @@ -569,7 +570,8 @@ app.post('/spawn', async (req, res) => { // A chunk that decolors down to whitespace was pure terminal control // (`opencode run` emits a bare reset per progress redraw). Tagging it // `[stderr]` would add one blank noise line to the tail per redraw. - if (!decolored.trim()) return; + const trimmedDecolored = decolored.trim(); + if (!trimmedDecolored || isKnownCliStderrNoise(trimmedDecolored)) return; const text = `[stderr] ${decolored}`; if (agent) agent.outputBuffer += text; emitToServer('agent:output', { agentId, text }); diff --git a/server/cos-runner/index.test.js b/server/cos-runner/index.test.js index 0424ed6134..a50e60a9b5 100644 --- a/server/cos-runner/index.test.js +++ b/server/cos-runner/index.test.js @@ -222,6 +222,6 @@ describe('cos-runner output — ANSI decoloring parity with spawnDirectly', () = }); it('drops a chunk that was only terminal control instead of emitting a blank line', () => { - expect(RUNNER_SRC).toMatch(/if \(!decolored\.trim\(\)\) return;/); + expect(RUNNER_SRC).toMatch(/if \(!trimmedDecolored \|\| isKnownCliStderrNoise\(trimmedDecolored\)\) return;/); }); }); diff --git a/server/lib/README.md b/server/lib/README.md index f49bc6e012..8ece344019 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -204,6 +204,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `localEndpoint.js` | Dependency-light local-instance URL predicates (`isLocalInstanceHost`, `isLocalInstanceEndpoint`, `localEndpointPort`) shared by provider safety policy and local-runtime classification without importing backend configuration or daemon managers. | | `cliProviderArgs.js` | Per-CLI argv conventions (`buildCliArgs`) for stdin prompt delivery — dependency-light extraction from runner.js so out-of-process callers (autofixer) can import it. | | `cliProviderRun.js` | One-shot CLI provider invocation (`pickCliProvider` + `runCliProviderPrompt`) — lightweight path for the autofixer + calendar MCP sync to honor the configured provider/model. | +| `cliStderrNoise.js` | `isKnownCliStderrNoise(trimmedLine)` — drops the real Anthropic `claude` CLI's harmless `[claude-code:unrecognized_model]` SDK telemetry line, which fires on every claude-ollama / claude-ollama-tui run because those providers point the binary's `ANTHROPIC_BASE_URL` at a local Ollama model the SDK doesn't recognize. | | `codex.js` | OpenAI Codex CLI (`codex`) provider helpers — the `CODEX_COMMAND`/`CODEX_CLI_ID` constants, the `isCodexCommand` predicate, and `ensureCodexTuiArgs` (injects `--dangerously-bypass-approvals-and-sandbox` + disables the startup update-check config, skipped when the argv already declares an approval/sandbox posture via `argvHasFlag`). Extracted from `tuiHandshake.js` (#3618) to match the one-file-per-vendor shape of `antigravity.js`/`grok.js`/`kimi.js`/`cursor.js`, which `providerVendors.js` consumes as registry rows. | | `cursor.js` | Cursor Agent (`cursor-agent`) provider helpers — the `CURSOR_COMMAND` binary constant, the `isCursorCommand` predicate, and the `ensureCursorHeadlessArgs` (`--print --force`, folding any pinned reasoning effort into `--model`) / `ensureCursorTuiArgs` (`--force`) argv builders. `--force` is load-bearing beyond approvals: it also clears cursor's workspace-trust gate, which otherwise EXITS a headless run before any work happens. The prompt rides raw stdin (like claude/codex, unlike grok/kimi), and cursor needs no configured-default sentinel — its `auto` router is a real model id passed straight to `--model`. | | `grok.js` | xAI Grok Build (`grok`) provider helpers — id/endpoint constants (`GROK_API_ID`/`GROK_CLI_ID`/`GROK_TUI_ID`/`GROK_API_ENDPOINT`), `isGrokCommand`/`isGrokCliProvider`/`isGrokTuiProvider` predicates, `ensureGrokHeadlessArgs`/`ensureGrokTuiArgs` argv builders (grok reads its prompt from `--prompt-file /dev/stdin`, not raw stdin; model selection uses the `GROK_CONFIGURED_DEFAULT` sentinel in `providerModels.js` so PortOS omits `--model` like Antigravity), and `prepareGrokPromptFile` (Windows temp-file delivery fallback). | diff --git a/server/lib/cliStderrNoise.js b/server/lib/cliStderrNoise.js new file mode 100644 index 0000000000..772452e4ca --- /dev/null +++ b/server/lib/cliStderrNoise.js @@ -0,0 +1,11 @@ +// The real Anthropic `claude` CLI logs this warning once per unrecognized +// model string whenever its SDK sees a `model` field it doesn't recognize as +// an Anthropic model. PortOS's claude-ollama / claude-ollama-tui providers +// deliberately redirect that binary's ANTHROPIC_BASE_URL at a local Ollama +// endpoint serving non-Anthropic models (e.g. "gemma3:27b"), so this fires on +// every run — it's harmless SDK telemetry, not a misconfiguration or error. +const CLAUDE_SDK_UNRECOGNIZED_MODEL_RE = /^\[claude-code:unrecognized_model\]/; + +export function isKnownCliStderrNoise(trimmedLine) { + return CLAUDE_SDK_UNRECOGNIZED_MODEL_RE.test(trimmedLine); +} diff --git a/server/lib/cliStderrNoise.test.js b/server/lib/cliStderrNoise.test.js new file mode 100644 index 0000000000..75b1fa9386 --- /dev/null +++ b/server/lib/cliStderrNoise.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { isKnownCliStderrNoise } from './cliStderrNoise.js'; + +describe('isKnownCliStderrNoise', () => { + it('drops the claude CLI SDK unrecognized-model telemetry line', () => { + expect(isKnownCliStderrNoise('[claude-code:unrecognized_model] {"model":"gemma3:27b","query_source":"sdk"}')).toBe(true); + }); + + it('keeps an unrelated stderr line', () => { + expect(isKnownCliStderrNoise('ECONNREFUSED 127.0.0.1:11434')).toBe(false); + }); +}); diff --git a/server/lib/index.js b/server/lib/index.js index 7a9b7d0508..0b5858e058 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -133,6 +133,7 @@ export * from './agentExecutionProfiles.js'; export * from './localEndpoint.js'; export * from './cliProviderArgs.js'; export * from './cliProviderRun.js'; +export * from './cliStderrNoise.js'; export * from './codex.js'; export * from './codexAccount.js'; export * from './codexTurn.js'; diff --git a/server/services/agentCliSpawning.js b/server/services/agentCliSpawning.js index e4d6cfb2bf..2026b7f3e2 100644 --- a/server/services/agentCliSpawning.js +++ b/server/services/agentCliSpawning.js @@ -26,6 +26,7 @@ import { normalizeReviewers } from '../lib/validation.js'; import { resolveReviewLoopOptions } from './codeReview.js'; import { safeJSONParse, PATHS, writeFileGuarded } from '../lib/fileUtils.js'; import { createCodexStderrFormatter } from '../lib/codexCliOutput.js'; +import { isKnownCliStderrNoise } from '../lib/cliStderrNoise.js'; import { createStreamingAnsiStripper } from '../lib/ansiStrip.js'; import { PROVIDER_TYPES } from '../lib/aiToolkit/constants.js'; import { createImmediateFallbackSignalDetector } from '../lib/aiToolkit/errorDetection.js'; @@ -686,7 +687,8 @@ export async function spawnDirectly({ // A chunk that decolors down to whitespace was pure terminal control // (`opencode run` emits a bare reset per progress redraw). Tagging it // `[stderr]` would add one blank noise line to the tail per redraw. - if (!text.trim()) return; + const trimmed = text.trim(); + if (!trimmed || isKnownCliStderrNoise(trimmed)) return; outputBuffer += `[stderr] ${text}`; await writeFileGuarded(outputFile, outputBuffer).catch(() => {}); outputBatcher.push(`[stderr] ${text}`); diff --git a/server/services/agentCliSpawning.test.js b/server/services/agentCliSpawning.test.js index 2875fbc1ef..9822ef2810 100644 --- a/server/services/agentCliSpawning.test.js +++ b/server/services/agentCliSpawning.test.js @@ -995,6 +995,21 @@ describe('stream error containment', () => { expect(emittedLines().some((line) => line.trim() === '')).toBe(false); }); + it('drops the claude CLI SDK unrecognized-model telemetry line from the surfaced tail', async () => { + const spawnPromise = spawnDirectly(textArgs()); + await new Promise((r) => setTimeout(r, 10)); + + fakeProcess.stderr.emit('data', Buffer.from( + '[claude-code:unrecognized_model] {"model":"gemma3:27b","query_source":"sdk"}\n' + )); + await new Promise((r) => setTimeout(r, 30)); + + fakeProcess.emit('close', 0); + await spawnPromise.catch(() => {}); + + expect(emittedLines().join('')).not.toMatch(/unrecognized_model/); + }); + it('still records a colors-only chunk as run output — it is proof the child is alive', async () => { // Counting the DECOLORED length would report zero bytes and file a run that // was steadily redrawing its progress line as having produced nothing.