product-console-companion 0.7.1 → 0.7.3

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.
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { buildLocalDesignContext } from './design-context.js';
17
17
  import { buildLocalRepoContext, listRepoFiles } from './local-scan.js';
18
18
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
19
+ import { replyText, streamWithFallback } from './text-model.js';
19
20
  const MAX_CANDIDATE_FILES = 8;
20
21
  /** A skip threshold, not a truncation — a file over this is left OUT of what's read entirely
21
22
  * (with a note in the log), rather than read and cut off partway through. The whole point of
@@ -25,7 +26,7 @@ const MAX_FILE_BYTES = 512 * 1024;
25
26
  /** Structured model-only call used by the investigation reader. The reader, not this call and
26
27
  * not the model, owns every repository read and evidence id. */
27
28
  export async function runStructuredModelCall(client, request) {
28
- const stream = client.messages.stream({
29
+ const stream = streamWithFallback(client, {
29
30
  model: request.model,
30
31
  max_tokens: request.maxTokens,
31
32
  thinking: { type: 'adaptive' },
@@ -46,14 +47,12 @@ export async function runStructuredModelCall(client, request) {
46
47
  }
47
48
  throw error;
48
49
  }
49
- if (message.stop_reason === 'refusal')
50
- throw new Error('The model declined the structured investigation call.');
51
50
  if (message.stop_reason === 'max_tokens')
52
51
  throw new Error('budget_exhausted: the structured investigation call reached its output-token limit.');
53
- const text = message.content.find((block) => block.type === 'text');
54
- if (!text || text.type !== 'text')
52
+ const text = replyText(message, 'The model declined the structured investigation call.');
53
+ if (!text)
55
54
  throw new Error('The structured investigation call returned no text.');
56
- return JSON.parse(text.text);
55
+ return JSON.parse(text);
57
56
  }
58
57
  export const fileSelectSchema = {
59
58
  type: 'object',
@@ -0,0 +1,121 @@
1
+ import crossSpawn from 'cross-spawn';
2
+ import { spawn as nodeSpawn } from 'node:child_process';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
5
+ const CMD_UNSAFE_ARGUMENT = /[\^<>|&%!]/;
6
+ const FALLBACK_ARGUMENT_ERROR = "claude resolves to a .cmd shim and this argument can't pass through cmd.exe intact; install Claude Code so claude.exe is reachable, or update the companion";
7
+ const defaultFs = { existsSync, readFileSync };
8
+ let cachedCommand = null;
9
+ function envValue(env, name) {
10
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
11
+ return key ? env[key] : undefined;
12
+ }
13
+ function windowsPathEntries(env) {
14
+ return (envValue(env, 'PATH') ?? '')
15
+ .split(';')
16
+ .map((entry) => entry.trim().replace(/^"|"$/g, ''))
17
+ .filter(Boolean);
18
+ }
19
+ function windowsExtensions(env) {
20
+ const pathExt = envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
21
+ return pathExt
22
+ .split(';')
23
+ .map((extension) => extension.trim().toLowerCase())
24
+ .filter(Boolean)
25
+ .map((extension) => extension.startsWith('.') ? extension : `.${extension}`);
26
+ }
27
+ function existingCandidate(directory, extension, fs) {
28
+ const lowerCaseCandidate = join(directory, `claude${extension.toLowerCase()}`);
29
+ if (fs.existsSync(lowerCaseCandidate))
30
+ return resolve(lowerCaseCandidate);
31
+ const originalCaseCandidate = join(directory, `claude${extension}`);
32
+ return fs.existsSync(originalCaseCandidate) ? resolve(originalCaseCandidate) : null;
33
+ }
34
+ function parsedShimExecutable(shimPath, fs) {
35
+ let contents;
36
+ try {
37
+ contents = fs.readFileSync(shimPath, 'utf8');
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ const shimDirectory = dirname(shimPath);
43
+ for (const match of contents.matchAll(/"([^"\r\n]*claude\.exe)"/gi)) {
44
+ let target = match[1].replace(/%(?:~dp0|dp0)%?/gi, shimDirectory);
45
+ // Tests inject win32 while running on Linux CI; normalizing both separator forms keeps the
46
+ // resolver testable with a real temporary filesystem and is a no-op for native Windows paths.
47
+ target = target.replace(/[\\/]/g, sep);
48
+ if (!isAbsolute(target))
49
+ target = resolve(shimDirectory, target);
50
+ if (fs.existsSync(target))
51
+ return resolve(target);
52
+ }
53
+ return null;
54
+ }
55
+ function executableForShim(shimPath, fs) {
56
+ const parsed = parsedShimExecutable(shimPath, fs);
57
+ if (parsed)
58
+ return parsed;
59
+ const conventional = join(dirname(shimPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
60
+ return fs.existsSync(conventional) ? resolve(conventional) : null;
61
+ }
62
+ function resolveUncached(platform, env, fs) {
63
+ if (platform !== 'win32') {
64
+ return { command: { command: 'claude', viaShell: true }, fallbackReason: null };
65
+ }
66
+ const directories = windowsPathEntries(env);
67
+ const extensions = windowsExtensions(env);
68
+ for (const directory of directories) {
69
+ for (const extension of extensions) {
70
+ if (extension !== '.cmd' && extension !== '.exe')
71
+ continue;
72
+ const candidate = existingCandidate(directory, extension, fs);
73
+ if (!candidate)
74
+ continue;
75
+ if (extension === '.exe') {
76
+ return { command: { command: candidate, viaShell: false }, fallbackReason: null };
77
+ }
78
+ const executable = executableForShim(candidate, fs);
79
+ if (executable) {
80
+ return { command: { command: executable, viaShell: false }, fallbackReason: null };
81
+ }
82
+ return {
83
+ command: { command: 'claude', viaShell: true },
84
+ fallbackReason: `Found ${candidate}, but its claude.exe target was not reachable; falling back to the .cmd shim.`,
85
+ };
86
+ }
87
+ }
88
+ return {
89
+ command: { command: 'claude', viaShell: true },
90
+ fallbackReason: 'No claude.exe or claude.cmd was found on PATH; falling back to cross-spawn command lookup.',
91
+ };
92
+ }
93
+ /** Resolve once in production. Injected dependencies bypass the cache so Linux CI can exercise
94
+ * the Windows lookup rules without mutating process globals or depending on Windows filesystem
95
+ * semantics. */
96
+ export function resolveClaudeCommand(options = {}) {
97
+ const injected = options.platform !== undefined || options.env !== undefined || options.fs !== undefined;
98
+ if (!injected && cachedCommand)
99
+ return cachedCommand;
100
+ const result = resolveUncached(options.platform ?? process.platform, options.env ?? process.env, options.fs ?? defaultFs);
101
+ if (!injected) {
102
+ cachedCommand = result.command;
103
+ if (result.fallbackReason)
104
+ console.warn(`[product-console-companion] ${result.fallbackReason}`);
105
+ }
106
+ return result.command;
107
+ }
108
+ export function assertClaudeArgumentsSafeForFallback(args, platform = process.platform) {
109
+ if (platform === 'win32' && args.some((argument) => CMD_UNSAFE_ARGUMENT.test(argument))) {
110
+ throw new Error(FALLBACK_ARGUMENT_ERROR);
111
+ }
112
+ }
113
+ /** Native executables go straight through CreateProcess via Node. cross-spawn remains only for
114
+ * the unresolved command-name path, where it supplies the existing .cmd compatibility behavior. */
115
+ export function spawnClaude(args, options = {}, resolution = resolveClaudeCommand()) {
116
+ if (resolution.viaShell) {
117
+ assertClaudeArgumentsSafeForFallback(args);
118
+ return crossSpawn(resolution.command, args, options);
119
+ }
120
+ return nodeSpawn(resolution.command, args, options);
121
+ }
@@ -10,15 +10,16 @@
10
10
  * --max-budget-usd's exceeded-budget behavior) was confirmed with real, budgeted `claude` calls
11
11
  * during development, not guessed from --help text alone — see the Phase 2 PR description.
12
12
  */
13
- import spawn from 'cross-spawn';
14
13
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
15
14
  import { tmpdir } from 'node:os';
16
15
  import { join } from 'node:path';
17
16
  import { createInterface } from 'node:readline';
18
17
  import { StringDecoder } from 'node:string_decoder';
19
18
  import { assertSingleLineArguments } from './cli-arguments.js';
19
+ import { spawnClaude } from './claude-binary.js';
20
20
  import { gitErrorMessage } from './sandbox.js';
21
21
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
22
+ import { ModelRefusalError } from './text-model.js';
22
23
  /** Independent of --max-budget-usd — a dollar cap doesn't prevent a network-stall hang. No
23
24
  * automatic retry on top of this (see cli.ts): auto-retry would risk double-spending the
24
25
  * customer's own subscription/API quota. */
@@ -85,6 +86,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
85
86
  assertSingleLineArguments('claude', args);
86
87
  const filesRead = new Set();
87
88
  let resultEnvelope = null;
89
+ let lastStopReason = null;
88
90
  let stderrTail = '';
89
91
  let timedOut = false;
90
92
  let stdinError = null;
@@ -98,7 +100,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
98
100
  let firstUnparseableLineFirst = '';
99
101
  let firstUnparseableLineLast = '';
100
102
  const typeCounts = new Map();
101
- const child = spawn('claude', args, { cwd: sandboxPath });
103
+ const child = spawnClaude(args, { cwd: sandboxPath });
102
104
  // stdout/stderr are only null when stdio is explicitly configured away from 'pipe' — the
103
105
  // default here, so both are always real streams in practice; the ! just satisfies the type.
104
106
  const stdoutDecoder = new StringDecoder('utf8');
@@ -152,6 +154,9 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
152
154
  resultEnvelope = event;
153
155
  }
154
156
  else if (eventType === 'assistant') {
157
+ const stopReason = event.message?.stop_reason;
158
+ if (typeof stopReason === 'string')
159
+ lastStopReason = stopReason;
155
160
  const blocks = event.message?.content ?? [];
156
161
  for (const b of blocks) {
157
162
  if (b.type === 'tool_use' && b.name === 'Read' && b.input?.file_path) {
@@ -211,6 +216,11 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
211
216
  const message = envelope.errors?.length ? envelope.errors.join('; ') : envelope.result;
212
217
  throw new Error(message || 'Unknown claude CLI error');
213
218
  }
219
+ // A refused turn can still end in a non-error result envelope; its result is empty or partial
220
+ // text, so it must fail here rather than be returned (or JSON-parsed) as an answer.
221
+ if (envelope.stop_reason === 'refusal' || lastStopReason === 'refusal') {
222
+ throw new ModelRefusalError('The model declined this request.');
223
+ }
214
224
  if (capturedStdinError) {
215
225
  throw promptWriteFailure(capturedStdinError);
216
226
  }
package/dist/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import spawn from 'cross-spawn';
4
3
  import { resolve } from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import { addConnection, configPath, enableWorkspacePolling, loadConfig } from './config.js';
@@ -8,6 +7,7 @@ import { CompanionClient, CompanionHttpError, PairingHttpError, } from './http-c
8
7
  import { assertIsRepo, checkRepositoryReadiness, currentCommit, GIT_UNAVAILABLE_MESSAGE, REMOTE_TROUBLESHOOTING_HINT, repositoryIdentity, RepositoryIdentityError, repositoryTopLevel, } from './local-scan.js';
9
8
  import { runAnalyseShipmentTask, runDesignPromptTask, runGenerateSpecBatchTask, runGenericTask, runProposeNewCapabilitiesTask, runProposeSkeletonTask, runStructuredModelCall, runVerifyModuleTask } from './api-key-caller.js';
10
9
  import { runAnalyseShipmentTaskViaCli, runDesignPromptTaskViaCli, runGenerateSpecBatchTaskViaCli, runGenericTaskViaCli, runProposeNewCapabilitiesTaskViaCli, runProposeSkeletonTaskViaCli, runStructuredModelCallViaCli, runVerifyModuleTaskViaCli } from './claude-cli-caller.js';
10
+ import { spawnClaude } from './claude-binary.js';
11
11
  import { checkCodexLogin, CODEX_CODE_MODE_MODELS, codexTextModel, defaultCodexHome, runStructuredModelCallViaCodex } from './codex-cli-caller.js';
12
12
  import { formatInvestigationReaderDiagnostic, INVESTIGATION_MODEL, runAndReportInvestigation } from './investigation-reader.js';
13
13
  import { anthropicTextModel } from './text-model.js';
@@ -80,7 +80,7 @@ function parseFlags(argv) {
80
80
  }
81
81
  async function checkClaudeCliAvailable() {
82
82
  return new Promise((resolvePromise) => {
83
- const child = spawn('claude', ['--version']);
83
+ const child = spawnClaude(['--version']);
84
84
  let out = '';
85
85
  child.stdout?.on('data', (chunk) => (out += chunk.toString()));
86
86
  child.on('error', () => resolvePromise(null)); // e.g. ENOENT — not installed / not on PATH
@@ -15,7 +15,7 @@ export const MAX_EVIDENCE_FILE_BYTES = 512 * 1024;
15
15
  * ceiling leaves at least 3x headroom while bounding serialized prompt overhead.
16
16
  */
17
17
  export const MAX_ASSESSMENT_PROMPT_CHARACTERS = 384 * 1024;
18
- export const INVESTIGATION_MODEL = 'claude-sonnet-5';
18
+ export const INVESTIGATION_MODEL = 'claude-sonnet-5-5';
19
19
  const execFileAsync = promisify(execFile);
20
20
  export class MissingPolicy4ExecutionError extends Error {
21
21
  name = 'MissingPolicy4ExecutionError';
@@ -7,7 +7,7 @@ import { DEFAULT_MAPPER_SETTINGS, enumerateTrackedFiles, expandImports, isSuppor
7
7
  import { repositoryMissingReason, spawnFailureCode } from './local-scan.js';
8
8
  import { createSandbox } from './sandbox.js';
9
9
  export const SURFACE_SEED_PROMPT_VERSION = 'map_module_seeds@1';
10
- export const SURFACE_SEED_MODEL = 'claude-sonnet-5';
10
+ export const SURFACE_SEED_MODEL = 'claude-sonnet-5-5';
11
11
  const execFileAsync = promisify(execFile);
12
12
  const MODEL_ROLES = [
13
13
  'implementation',
@@ -36,6 +36,34 @@ const PURPOSE_ERRORS = {
36
36
  empty: 'No result returned.',
37
37
  },
38
38
  };
39
+ /** Server-side refusal fallback: on a policy decline the API re-runs the request on Anthropic's
40
+ * recommended substitute inside the same call. The scalar "default" form needs exactly this header
41
+ * (the array form is gated by -2026-06-01; pairing either header with the other form is a 400). */
42
+ const FALLBACK_BETA = 'server-side-fallback-2026-07-01';
43
+ /** SDK 0.111 types `fallbacks` as the array form only; the API also takes the scalar "default". */
44
+ const DEFAULT_FALLBACKS = 'default';
45
+ /** Every API-key call goes through here, so each one carries the refusal fallback. */
46
+ export function streamWithFallback(client, params) {
47
+ return client.beta.messages.stream({ ...params, betas: [FALLBACK_BETA], fallbacks: DEFAULT_FALLBACKS });
48
+ }
49
+ /** stop_reason 'refusal' — after the fallback, the whole chain declined. Thrown before content is
50
+ * read, so a partial or empty reply is never parsed as a result. */
51
+ export class ModelRefusalError extends Error {
52
+ category;
53
+ constructor(message, category = null) {
54
+ super(message);
55
+ this.name = 'ModelRefusalError';
56
+ this.category = category;
57
+ }
58
+ }
59
+ /** The reply text once a refusal is ruled out. A mid-output fallback continues the declined
60
+ * model's partial text in a later block, so every text block is joined; null when there is none. */
61
+ export function replyText(message, refusal) {
62
+ if (message.stop_reason === 'refusal')
63
+ throw new ModelRefusalError(refusal, message.stop_details?.category ?? null);
64
+ const text = message.content.map((block) => (block.type === 'text' ? block.text : '')).join('');
65
+ return text || null;
66
+ }
39
67
  function usageRecord(value) {
40
68
  if (!value || typeof value !== 'object' || Array.isArray(value))
41
69
  return null;
@@ -68,7 +96,7 @@ export function anthropicTextModel(client) {
68
96
  const content = request.purpose.endsWith(':file_select') && blocks.length === 1 && blocks[0].type === 'text'
69
97
  ? blocks[0].text
70
98
  : blocks;
71
- const stream = client.messages.stream({
99
+ const stream = streamWithFallback(client, {
72
100
  model: request.model,
73
101
  max_tokens: request.maxTokens,
74
102
  thinking: { type: 'adaptive' },
@@ -83,11 +111,11 @@ export function anthropicTextModel(client) {
83
111
  refusal: `The model declined the ${request.purpose} call.`,
84
112
  empty: `The ${request.purpose} call returned no text.`,
85
113
  };
86
- if (message.stop_reason === 'refusal' && errors.refusal)
87
- throw new Error(errors.refusal);
88
- const text = message.content.find((block) => block.type === 'text');
89
- if (!text || text.type !== 'text')
114
+ // A purpose with no refusal wording of its own still fails as a refusal, under its empty-reply
115
+ // wording — never by reading whatever partial text the declined attempt left behind.
116
+ const text = replyText(message, errors.refusal ?? errors.empty);
117
+ if (!text)
90
118
  throw new Error(errors.empty);
91
- return { text: text.text, usage: usageRecord(message.usage) };
119
+ return { text, usage: usageRecord(message.usage) };
92
120
  };
93
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-console-companion",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Local-first BYOS companion for the Fynch Product Console — reads code and runs AI calls from your own machine instead of the hosted server.",
5
5
  "type": "module",
6
6
  "license": "MIT",