patina-cli 3.11.0 → 4.0.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.
Files changed (193) hide show
  1. package/.patina.default.yaml +29 -29
  2. package/CHANGELOG.md +53 -0
  3. package/NOTICE +21 -0
  4. package/README.md +117 -224
  5. package/README_JA.md +134 -77
  6. package/README_KR.md +132 -74
  7. package/README_ZH.md +137 -80
  8. package/SKILL.md +11 -20
  9. package/artifacts/rebaseline-2025/README.md +147 -0
  10. package/artifacts/rebaseline-2025/human-controls.public.jsonl +250 -0
  11. package/artifacts/rebaseline-2025/intake.example.jsonl +2 -0
  12. package/artifacts/rebaseline-2025/intake.local.example.jsonl +25 -0
  13. package/artifacts/rebaseline-2025/prompts.template.jsonl +7 -0
  14. package/artifacts/rebaseline-2025/sources.ko-public.jsonl +39 -0
  15. package/assets/brand/patina-badge.svg +18 -0
  16. package/assets/brand/patina-mark.svg +8 -0
  17. package/assets/demo/README.md +79 -0
  18. package/core/scoring.md +12 -12
  19. package/core/standalone-prompt.md +3 -1
  20. package/core/stylometry.md +93 -22
  21. package/docs/API.md +1554 -0
  22. package/docs/AUTHENTICATION.md +50 -26
  23. package/docs/AUTHENTICATION_KR.md +54 -29
  24. package/docs/BRANDING.md +9 -8
  25. package/docs/CLI.md +55 -14
  26. package/docs/COOKBOOK.md +8 -21
  27. package/docs/DEMO.md +32 -5
  28. package/docs/EXIT-CODES.md +2 -3
  29. package/docs/FALSE-POSITIVES.md +63 -0
  30. package/docs/FAQ.md +9 -1
  31. package/docs/FAQ_KR.md +3 -1
  32. package/docs/FLAG-PARITY.md +33 -47
  33. package/docs/ISSUE-WAVES.md +57 -0
  34. package/docs/PATTERNS-EN.md +67 -3
  35. package/docs/PATTERNS-JA.md +68 -2
  36. package/docs/PATTERNS-KO.md +70 -7
  37. package/docs/PATTERNS-ZH.md +67 -3
  38. package/docs/PATTERNS.md +5 -5
  39. package/docs/RESEARCH-DOCS-PLATFORM.md +54 -0
  40. package/docs/ROADMAP.md +46 -66
  41. package/docs/TRANSLATIONESE-KO.md +51 -0
  42. package/docs/audits/2026-05-deep-research.md +3 -1
  43. package/docs/benchmarks/README.md +51 -0
  44. package/docs/benchmarks/detector-comparison.json +69 -9
  45. package/docs/benchmarks/detector-comparison.md +10 -5
  46. package/docs/benchmarks/katfish-ko-latest.json +657 -0
  47. package/docs/benchmarks/katfish-ko-latest.md +77 -0
  48. package/docs/benchmarks/latest.json +1183 -108
  49. package/docs/benchmarks/latest.md +84 -60
  50. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.json +1121 -0
  51. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.md +136 -0
  52. package/docs/benchmarks/rebaseline-latest.json +381 -0
  53. package/docs/benchmarks/rebaseline-latest.md +121 -0
  54. package/docs/benchmarks/register-stratified-latest.json +164 -0
  55. package/docs/benchmarks/register-stratified-latest.md +99 -0
  56. package/docs/benchmarks/register-stratified.md +43 -0
  57. package/docs/integrations/github-action.md +44 -11
  58. package/docs/integrations/playground.md +58 -0
  59. package/docs/integrations/pre-commit.md +5 -5
  60. package/docs/integrations/release.md +5 -3
  61. package/docs/integrations/static-sites.md +83 -0
  62. package/docs/research/2025-rebaseline-plan.md +71 -2
  63. package/docs/research/2026-rebaseline.md +102 -0
  64. package/docs/research/adversarial-mps.md +41 -0
  65. package/docs/research/ai-human-metrics.md +35 -23
  66. package/docs/research/human-eval-panel.md +42 -0
  67. package/docs/research/judge-agreement.md +24 -0
  68. package/docs/research/ko-2025-corpus-sources.md +135 -0
  69. package/docs/research/lexicon-freshness-audit.md +64 -0
  70. package/docs/research/zh-ja-lexicon-calibration.md +60 -0
  71. package/docs/social/patina-launch-copy.md +173 -100
  72. package/docs/social/patina-launch-execution.md +94 -0
  73. package/docs/social/patina-launch-korean-first.md +83 -0
  74. package/docs/social/signs-of-ai-writing.md +26 -0
  75. package/docs/social/signs-of-ai-writing_KR.md +26 -0
  76. package/lexicon/ai-en.md +21 -24
  77. package/lexicon/ai-ja.md +158 -0
  78. package/lexicon/ai-ko.md +9 -9
  79. package/lexicon/ai-zh.md +158 -0
  80. package/lexicon/provenance/ai-en.json +970 -0
  81. package/lexicon/provenance/ai-ja.json +542 -0
  82. package/lexicon/provenance/ai-ko.json +866 -0
  83. package/lexicon/provenance/ai-zh.json +542 -0
  84. package/package.json +49 -8
  85. package/patterns/en-communication.md +5 -0
  86. package/patterns/en-content.md +5 -0
  87. package/patterns/en-filler.md +5 -0
  88. package/patterns/en-language.md +29 -1
  89. package/patterns/en-structure.md +5 -0
  90. package/patterns/en-style.md +5 -0
  91. package/patterns/en-viral-hook.md +42 -2
  92. package/patterns/ja-communication.md +5 -0
  93. package/patterns/ja-content.md +5 -0
  94. package/patterns/ja-filler.md +5 -0
  95. package/patterns/ja-language.md +33 -1
  96. package/patterns/ja-structure.md +12 -0
  97. package/patterns/ja-style.md +5 -0
  98. package/patterns/ja-viral-hook.md +41 -2
  99. package/patterns/ko-communication.md +5 -0
  100. package/patterns/ko-content.md +5 -0
  101. package/patterns/ko-filler.md +5 -0
  102. package/patterns/ko-language.md +33 -1
  103. package/patterns/ko-structure.md +25 -6
  104. package/patterns/ko-style.md +5 -0
  105. package/patterns/ko-viral-hook.md +38 -2
  106. package/patterns/zh-communication.md +5 -0
  107. package/patterns/zh-content.md +5 -0
  108. package/patterns/zh-filler.md +5 -0
  109. package/patterns/zh-language.md +37 -1
  110. package/patterns/zh-structure.md +12 -0
  111. package/patterns/zh-style.md +5 -0
  112. package/patterns/zh-viral-hook.md +38 -2
  113. package/playground/README.md +55 -0
  114. package/playground/analytics.js +4 -0
  115. package/playground/analyzer.js +883 -0
  116. package/playground/app.js +157 -0
  117. package/playground/data/lexicons.js +343 -0
  118. package/playground/index.html +138 -0
  119. package/playground/styles.css +267 -0
  120. package/profiles/namuwiki.md +111 -0
  121. package/scripts/adversarial-mps-report.mjs +201 -0
  122. package/scripts/badge-json.mjs +79 -0
  123. package/scripts/benchmark-report.mjs +56 -9
  124. package/scripts/check-release-metadata.mjs +0 -2
  125. package/scripts/detector-comparison.mjs +7 -7
  126. package/scripts/generate-playground-data.mjs +77 -0
  127. package/scripts/katfish-calibration.mjs +464 -0
  128. package/scripts/lexicon-freshness.mjs +485 -0
  129. package/scripts/lint.mjs +1 -1
  130. package/scripts/precommit-score.mjs +4 -3
  131. package/scripts/prose-score.mjs +81 -5
  132. package/scripts/rebaseline-intake.mjs +242 -0
  133. package/scripts/rebaseline-score.mjs +268 -0
  134. package/scripts/rebaseline-summary.mjs +773 -0
  135. package/scripts/rebaseline-web-collect.mjs +410 -0
  136. package/scripts/update-benchmark-ranges.mjs +1 -0
  137. package/src/api.js +69 -105
  138. package/src/auth.js +50 -2
  139. package/src/backends/claude-cli.js +19 -4
  140. package/src/backends/codex-cli.js +19 -3
  141. package/src/backends/contract.js +230 -1
  142. package/src/backends/gemini-cli.js +18 -5
  143. package/src/backends/index.js +87 -12
  144. package/src/backends/kimi-cli.js +161 -0
  145. package/src/cli.js +577 -567
  146. package/src/commands/doctor.js +2 -2
  147. package/src/config.js +29 -0
  148. package/src/errors.js +53 -1
  149. package/src/features/discourse-tells.js +68 -0
  150. package/src/features/index.js +82 -8
  151. package/src/features/lexicon.js +40 -6
  152. package/src/features/markup-leakage.js +69 -0
  153. package/src/features/segment.js +41 -0
  154. package/src/features/signal-strength.js +81 -0
  155. package/src/features/stylometry.js +231 -1
  156. package/src/features/translationese.js +127 -0
  157. package/src/loader.js +76 -0
  158. package/src/logger.js +22 -23
  159. package/src/model-defaults.js +55 -0
  160. package/src/ouroboros.js +31 -0
  161. package/src/output.js +102 -90
  162. package/src/prompt-builder.js +103 -68
  163. package/src/providers.js +51 -4
  164. package/src/scoring.js +210 -2
  165. package/src/security.js +75 -0
  166. package/tests/fixtures/live-quality/en/public-docs-01.md +26 -0
  167. package/tests/fixtures/live-quality/ko/public-docs-01.md +26 -0
  168. package/tests/fixtures/suspect-zones/expected-ranges.json +207 -16
  169. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-04-lexicon.md +11 -0
  170. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-04-lexicon-cold.md +11 -0
  171. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +4 -5
  172. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-07-ko-diagnostic.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-04-lexicon.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-04-lexicon-cold.md +11 -0
  175. package/tests/quality/README.md +188 -11
  176. package/tests/quality/adversarial-mps/fixtures.jsonl +10 -0
  177. package/tests/quality/benchmark.mjs +39 -1
  178. package/tests/quality/dogfood.mjs +5 -3
  179. package/tests/quality/live-fixtures.jsonl +2 -0
  180. package/tests/quality/live-quality.mjs +596 -0
  181. package/tests/quality/ranking-metrics.mjs +136 -0
  182. package/tests/quality/rebaseline-manifest.example.jsonl +5 -0
  183. package/vercel.json +53 -0
  184. package/SKILL-MAX.md +0 -455
  185. package/docs/internal/HARNESS.md +0 -14
  186. package/docs/internal/README.md +0 -14
  187. package/docs/internal/WARP.md +0 -23
  188. package/patina-max/SKILL.md +0 -523
  189. package/patina-max/composite.py +0 -457
  190. package/src/cache.js +0 -106
  191. package/src/commands/init.js +0 -208
  192. package/src/manifest.js +0 -162
  193. package/src/max-mode.js +0 -207
package/src/auth.js CHANGED
@@ -1,26 +1,63 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { inputError } from './errors.js';
3
3
 
4
+ /**
5
+ * Environment variable names checked for HTTP provider authentication.
6
+ *
7
+ * @type {string[]}
8
+ * @example
9
+ * const supported = HTTP_KEY_ENV_VARS.includes('OPENAI_API_KEY');
10
+ */
4
11
  export const HTTP_KEY_ENV_VARS = [
5
12
  'PATINA_API_KEY',
6
13
  'OPENAI_API_KEY',
7
14
  'GEMINI_API_KEY',
8
15
  'GROQ_API_KEY',
9
16
  'TOGETHER_API_KEY',
17
+ 'KIMI_API_KEY',
18
+ 'MOONSHOT_API_KEY',
10
19
  ];
11
20
 
12
21
  // Default openai-http runs against the OpenAI-compatible default endpoint, so
13
22
  // only generic/OpenAI keys make it authenticated without an explicit provider.
23
+ /**
24
+ * Default key lookup order for the OpenAI-compatible HTTP provider.
25
+ *
26
+ * @type {string[]}
27
+ * @example
28
+ * const first = DEFAULT_HTTP_KEY_ENV_VARS[0]; // PATINA_API_KEY
29
+ */
14
30
  export const DEFAULT_HTTP_KEY_ENV_VARS = [
15
31
  'PATINA_API_KEY',
16
32
  'OPENAI_API_KEY',
17
33
  ];
18
34
 
35
+ /**
36
+ * Build the key lookup order for a selected provider.
37
+ *
38
+ * @param {string} [providerApiKeyEnv] Provider-specific key env var, such as GEMINI_API_KEY.
39
+ * @returns {string[]} Unique env var names in lookup order.
40
+ * @throws {Error} Does not intentionally throw; invalid non-string env names can still propagate JavaScript runtime failures.
41
+ * @example
42
+ * const vars = providerHttpKeyEnvVars('GEMINI_API_KEY');
43
+ */
19
44
  export function providerHttpKeyEnvVars(providerApiKeyEnv) {
20
45
  if (!providerApiKeyEnv) return DEFAULT_HTTP_KEY_ENV_VARS;
21
46
  return uniqueEnvVars([providerApiKeyEnv, 'PATINA_API_KEY']);
22
47
  }
23
48
 
49
+ /**
50
+ * Inspect where an HTTP API key would be read from without exposing the secret.
51
+ *
52
+ * @param {object} [options] Inspection options.
53
+ * @param {object} [options.env=process.env] Environment map to inspect.
54
+ * @param {Function} [options.readFile] File reader for PATINA_API_KEY_FILE.
55
+ * @param {string[]} [options.envVars=DEFAULT_HTTP_KEY_ENV_VARS] Env vars to check.
56
+ * @returns {{ok: boolean, source: string|null, envVars: string[], filePath: string|null, detail: string}} Source diagnostics.
57
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
58
+ * @example
59
+ * const source = inspectHttpApiKeySource({ env: { PATINA_API_KEY: 'sk-...' } });
60
+ */
24
61
  export function inspectHttpApiKeySource({
25
62
  env = process.env,
26
63
  readFile = readFileSync,
@@ -48,8 +85,20 @@ export function inspectHttpApiKeySource({
48
85
  };
49
86
  }
50
87
 
88
+ /**
89
+ * Resolve the HTTP API key from a key file or environment.
90
+ *
91
+ * @param {object} [options] Resolution options.
92
+ * @param {string} [options.apiKeyFile] Explicit key file path.
93
+ * @param {object} [options.env=process.env] Environment map.
94
+ * @param {Function} [options.readFile] File reader for key files.
95
+ * @param {string[]} [options.envVars=DEFAULT_HTTP_KEY_ENV_VARS] Env var lookup order.
96
+ * @returns {string|undefined} Resolved key value, or undefined when unauthenticated.
97
+ * @throws {PatinaCliError} When the configured key file cannot be read or is empty.
98
+ * @example
99
+ * const key = resolveHttpApiKey({ env: process.env });
100
+ */
51
101
  export function resolveHttpApiKey({
52
- explicitApiKey,
53
102
  apiKeyFile,
54
103
  env = process.env,
55
104
  readFile = readFileSync,
@@ -68,7 +117,6 @@ export function resolveHttpApiKey({
68
117
  return file.key;
69
118
  }
70
119
 
71
- if (explicitApiKey) return explicitApiKey;
72
120
 
73
121
  const source = inspectHttpApiKeySource({ env, readFile, envVars });
74
122
  return source.ok && source.source !== 'PATINA_API_KEY_FILE'
@@ -2,9 +2,12 @@ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { DEFAULT_BACKEND_TIMEOUT_MS } from './contract.js';
5
+ import { DEFAULT_BACKEND_TIMEOUT_MS, runInteractiveCommand } from './contract.js';
6
+ import { resolveLocalCliModel } from '../model-defaults.js';
6
7
 
7
8
  export const name = 'claude-cli';
9
+ export const loginCommand = 'claude auth login';
10
+ export const installHint = 'Install Claude Code first, then run `patina auth login claude-cli` again.';
8
11
 
9
12
  export function isAvailable() {
10
13
  try {
@@ -23,22 +26,34 @@ export function isAuthenticated() {
23
26
  }
24
27
 
25
28
  export function authHint() {
26
- return 'Run `claude` once interactively and follow the OAuth prompt to authenticate (uses your Claude subscription, no API key needed).';
29
+ return `Run \`${loginCommand}\` and follow the OAuth prompt to authenticate (uses your Claude subscription, no API key needed).`;
27
30
  }
28
31
 
29
- export async function invoke({ prompt, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
32
+ export function login(options = {}) {
33
+ return runInteractiveCommand({
34
+ backendName: name,
35
+ command: 'claude',
36
+ args: ['auth', 'login'],
37
+ notFoundHint: installHint,
38
+ ...options,
39
+ });
40
+ }
41
+
42
+ export async function invoke({ prompt, model, modelSource, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
30
43
  if (!prompt || typeof prompt !== 'string') {
31
44
  throw new Error('claude-cli backend: prompt must be a non-empty string');
32
45
  }
33
46
  throwIfAborted(signal);
34
47
 
48
+ const cliModel = resolveLocalCliModel({ backendName: name, model, modelSource });
49
+
35
50
  // Spawn from a fresh temp directory so a prompt-injection in user text
36
51
  // cannot read or write inside the caller's repo. claude -p prints to
37
52
  // stdout, so no output file plumbing is needed (unlike codex-cli).
38
53
  const dir = mkdtempSync(join(tmpdir(), 'patina-claude-'));
39
54
 
40
55
  return new Promise((resolve, reject) => {
41
- const proc = spawn('claude', ['-p'], { stdio: ['pipe', 'pipe', 'pipe'], cwd: dir });
56
+ const proc = spawn('claude', ['-p', '--model', cliModel], { stdio: ['pipe', 'pipe', 'pipe'], cwd: dir });
42
57
 
43
58
  let stdout = '';
44
59
  let stderr = '';
@@ -2,9 +2,12 @@ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { DEFAULT_BACKEND_TIMEOUT_MS } from './contract.js';
5
+ import { DEFAULT_BACKEND_TIMEOUT_MS, runInteractiveCommand } from './contract.js';
6
+ import { resolveLocalCliModel } from '../model-defaults.js';
6
7
 
7
8
  export const name = 'codex-cli';
9
+ export const loginCommand = 'codex login';
10
+ export const installHint = 'Install it from https://github.com/openai/codex, then run `patina auth login codex-cli` again.';
8
11
 
9
12
  export function isAvailable() {
10
13
  try {
@@ -20,15 +23,27 @@ export function isAuthenticated() {
20
23
  }
21
24
 
22
25
  export function authHint() {
23
- return 'Run `codex login` to authenticate (uses your ChatGPT Plus account, no API key needed).';
26
+ return `Run \`${loginCommand}\` to authenticate (uses your ChatGPT Plus account, no API key needed).`;
24
27
  }
25
28
 
26
- export async function invoke({ prompt, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
29
+ export function login(options = {}) {
30
+ return runInteractiveCommand({
31
+ backendName: name,
32
+ command: 'codex',
33
+ args: ['login'],
34
+ notFoundHint: installHint,
35
+ ...options,
36
+ });
37
+ }
38
+
39
+ export async function invoke({ prompt, model, modelSource, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
27
40
  if (!prompt || typeof prompt !== 'string') {
28
41
  throw new Error('codex-cli backend: prompt must be a non-empty string');
29
42
  }
30
43
  throwIfAborted(signal);
31
44
 
45
+ const cliModel = resolveLocalCliModel({ backendName: name, model, modelSource });
46
+
32
47
  // Run codex from a fresh temp directory with the read-only sandbox so that
33
48
  // a prompt-injection in user text cannot read the caller's repo or write
34
49
  // arbitrary files. The output file lives inside the same temp dir so codex
@@ -42,6 +57,7 @@ export async function invoke({ prompt, signal, timeout = DEFAULT_BACKEND_TIMEOUT
42
57
  '--skip-git-repo-check',
43
58
  '--sandbox', 'read-only',
44
59
  '-C', dir,
60
+ '--model', cliModel,
45
61
  '--output-last-message', outFile,
46
62
  ], { stdio: ['pipe', 'pipe', 'pipe'], cwd: dir });
47
63
 
@@ -1,4 +1,69 @@
1
- export const DEFAULT_BACKEND_TIMEOUT_MS = 180_000;
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export const DEFAULT_BACKEND_TIMEOUT_MS = 600_000;
7
+ export const DEFAULT_HTTP_MAX_RETRIES = 2;
8
+ export const PROMPT_SIZE_WARNING_CHARS = 20_000;
9
+
10
+ export const BACKEND_SAFETY_DEFAULTS = Object.freeze({
11
+ 'openai-http': {
12
+ maxConcurrency: 4,
13
+ maxRetries: DEFAULT_HTTP_MAX_RETRIES,
14
+ promptMode: 'strict',
15
+ agentRuntime: false,
16
+ },
17
+ 'codex-cli': {
18
+ maxConcurrency: 2,
19
+ maxRetries: 0,
20
+ promptMode: 'minimal',
21
+ agentRuntime: true,
22
+ },
23
+ 'claude-cli': {
24
+ maxConcurrency: 1,
25
+ maxRetries: 0,
26
+ promptMode: 'minimal',
27
+ agentRuntime: true,
28
+ },
29
+ 'gemini-cli': {
30
+ maxConcurrency: 2,
31
+ maxRetries: 0,
32
+ promptMode: 'minimal',
33
+ agentRuntime: true,
34
+ },
35
+ 'kimi-cli': {
36
+ maxConcurrency: 1,
37
+ maxRetries: 0,
38
+ promptMode: 'minimal',
39
+ agentRuntime: true,
40
+ },
41
+ });
42
+
43
+ const UNKNOWN_BACKEND_SAFETY = Object.freeze({
44
+ maxConcurrency: Infinity,
45
+ maxRetries: 0,
46
+ promptMode: 'strict',
47
+ agentRuntime: false,
48
+ });
49
+
50
+ export function getBackendSafety(backendName) {
51
+ return BACKEND_SAFETY_DEFAULTS[backendName] || UNKNOWN_BACKEND_SAFETY;
52
+ }
53
+
54
+ export function resolveBackendMaxConcurrency(backendName, override) {
55
+ const n = override === undefined || override === null
56
+ ? getBackendSafety(backendName).maxConcurrency
57
+ : Number(override);
58
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : Infinity;
59
+ }
60
+
61
+ export function resolveBackendMaxRetries(backendName, override) {
62
+ const n = override === undefined || override === null
63
+ ? getBackendSafety(backendName).maxRetries
64
+ : Number(override);
65
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
66
+ }
2
67
 
3
68
  export function isRetryableBackendError(err, { attemptIndex = 0, signal } = {}) {
4
69
  if (signal?.aborted) return false;
@@ -10,6 +75,8 @@ export function isRetryableBackendError(err, { attemptIndex = 0, signal } = {})
10
75
  export function describeBackendError(err) {
11
76
  const status = extractStatus(err);
12
77
  if (status) return `HTTP ${status}`;
78
+ const exitCode = extractExitCode(err);
79
+ if (exitCode) return `exit code ${exitCode}`;
13
80
  return err?.name || 'error';
14
81
  }
15
82
 
@@ -19,3 +86,165 @@ function extractStatus(err) {
19
86
  const match = String(err?.message || '').match(/\bHTTP\s+(429|503)\b/);
20
87
  return match ? Number(match[1]) : null;
21
88
  }
89
+
90
+ function extractExitCode(err) {
91
+ const direct = Number(err?.code);
92
+ if (Number.isFinite(direct)) return direct;
93
+ const match = String(err?.message || '').match(/\bexited with code\s+(\d+)\b/i);
94
+ return match ? Number(match[1]) : null;
95
+ }
96
+
97
+ export async function withBackendConcurrencySlot({
98
+ backendName,
99
+ maxConcurrency,
100
+ signal,
101
+ timeout = DEFAULT_BACKEND_TIMEOUT_MS,
102
+ pollMs = 250,
103
+ staleMs = Math.max(timeout * 2, 30 * 60_000),
104
+ fn,
105
+ } = {}) {
106
+ if (typeof fn !== 'function') {
107
+ throw new Error('backend concurrency slot requires fn');
108
+ }
109
+ if (!Number.isFinite(maxConcurrency)) {
110
+ return fn();
111
+ }
112
+
113
+ const slot = await acquireBackendSlot({
114
+ backendName,
115
+ maxConcurrency,
116
+ signal,
117
+ timeout,
118
+ pollMs,
119
+ staleMs,
120
+ });
121
+
122
+ try {
123
+ return await fn();
124
+ } finally {
125
+ releaseBackendSlot(slot);
126
+ }
127
+ }
128
+
129
+ async function acquireBackendSlot({
130
+ backendName,
131
+ maxConcurrency,
132
+ signal,
133
+ timeout,
134
+ pollMs,
135
+ staleMs,
136
+ }) {
137
+ throwIfAborted(signal, `${backendName || 'backend'}: aborted while waiting for concurrency slot`);
138
+ const startedAt = Date.now();
139
+ const root = join(tmpdir(), 'patina-backend-slots', safePathSegment(backendName || 'backend'));
140
+ mkdirSync(root, { recursive: true });
141
+
142
+ for (;;) {
143
+ for (let index = 0; index < maxConcurrency; index++) {
144
+ const slot = join(root, `slot-${index}`);
145
+ cleanupStaleSlot(slot, staleMs);
146
+ try {
147
+ mkdirSync(slot);
148
+ writeFileSync(join(slot, 'owner.json'), JSON.stringify({
149
+ pid: process.pid,
150
+ backendName,
151
+ createdAt: new Date().toISOString(),
152
+ }), 'utf8');
153
+ return slot;
154
+ } catch (err) {
155
+ if (err?.code !== 'EEXIST') throw err;
156
+ }
157
+ }
158
+
159
+ throwIfAborted(signal, `${backendName || 'backend'}: aborted while waiting for concurrency slot`);
160
+ if (Date.now() - startedAt >= timeout) {
161
+ throw new Error(`${backendName || 'backend'}: timed out waiting for concurrency slot (cap ${maxConcurrency})`);
162
+ }
163
+ await sleepWithAbort(Math.min(pollMs, timeout), signal, backendName);
164
+ }
165
+ }
166
+
167
+ function cleanupStaleSlot(slot, staleMs) {
168
+ try {
169
+ const ageMs = Date.now() - statSync(slot).mtimeMs;
170
+ if (ageMs > staleMs) rmSync(slot, { recursive: true, force: true });
171
+ } catch {}
172
+ }
173
+
174
+ function releaseBackendSlot(slot) {
175
+ try {
176
+ rmSync(slot, { recursive: true, force: true });
177
+ } catch {}
178
+ }
179
+
180
+ function sleepWithAbort(ms, signal, backendName) {
181
+ if (ms <= 0) return Promise.resolve();
182
+ if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
183
+ return new Promise((resolve, reject) => {
184
+ const timer = setTimeout(() => {
185
+ cleanup();
186
+ resolve();
187
+ }, ms);
188
+ const onAbort = () => {
189
+ cleanup();
190
+ reject(abortError(`${backendName || 'backend'}: aborted while waiting for concurrency slot`));
191
+ };
192
+ const cleanup = () => {
193
+ clearTimeout(timer);
194
+ signal.removeEventListener('abort', onAbort);
195
+ };
196
+ signal.addEventListener('abort', onAbort, { once: true });
197
+ });
198
+ }
199
+
200
+ function throwIfAborted(signal, message) {
201
+ if (signal?.aborted) throw abortError(message);
202
+ }
203
+
204
+ function abortError(message) {
205
+ const err = new Error(message);
206
+ err.name = 'AbortError';
207
+ return err;
208
+ }
209
+
210
+ function safePathSegment(value) {
211
+ return String(value).replace(/[^a-z0-9._-]+/gi, '_');
212
+ }
213
+
214
+ export function runInteractiveCommand({
215
+ backendName,
216
+ command,
217
+ args = [],
218
+ cwd = process.cwd(),
219
+ env = process.env,
220
+ stdio = 'inherit',
221
+ notFoundHint,
222
+ } = {}) {
223
+ if (!backendName || !command) {
224
+ throw new Error('interactive backend command requires backendName and command');
225
+ }
226
+
227
+ return new Promise((resolve, reject) => {
228
+ const proc = spawn(command, args, { cwd, env, stdio });
229
+
230
+ proc.on('error', (err) => {
231
+ if (err.code === 'ENOENT') {
232
+ reject(new Error(`${backendName}: \`${command}\` CLI not found. ${notFoundHint || 'Install the CLI and try again.'}`));
233
+ return;
234
+ }
235
+ reject(new Error(`${backendName}: failed to spawn ${command} (${err.message})`));
236
+ });
237
+
238
+ proc.on('close', (code, signal) => {
239
+ if (code === 0) {
240
+ resolve();
241
+ return;
242
+ }
243
+ if (signal) {
244
+ reject(new Error(`${backendName}: ${command} was terminated by ${signal}`));
245
+ return;
246
+ }
247
+ reject(new Error(`${backendName}: ${command} exited with code ${code}`));
248
+ });
249
+ });
250
+ }
@@ -2,9 +2,12 @@ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { DEFAULT_BACKEND_TIMEOUT_MS } from './contract.js';
5
+ import { DEFAULT_BACKEND_TIMEOUT_MS, runInteractiveCommand } from './contract.js';
6
+ import { resolveLocalCliModel } from '../model-defaults.js';
6
7
 
7
8
  export const name = 'gemini-cli';
9
+ export const loginCommand = 'gemini';
10
+ export const installHint = 'Install Gemini CLI first, then run `patina auth login gemini-cli` again.';
8
11
 
9
12
  export function isAvailable() {
10
13
  try {
@@ -28,10 +31,20 @@ export function authHint() {
28
31
  if (process.env.GEMINI_API_KEY) {
29
32
  return 'Authenticated via GEMINI_API_KEY env var.';
30
33
  }
31
- return 'Run `gemini` once interactively to log in via Google OAuth, or set GEMINI_API_KEY.';
34
+ return `Run \`${loginCommand}\` once interactively to log in via Google OAuth, or set GEMINI_API_KEY.`;
32
35
  }
33
36
 
34
- export async function invoke({ prompt, model, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
37
+ export function login(options = {}) {
38
+ return runInteractiveCommand({
39
+ backendName: name,
40
+ command: 'gemini',
41
+ args: [],
42
+ notFoundHint: installHint,
43
+ ...options,
44
+ });
45
+ }
46
+
47
+ export async function invoke({ prompt, model, modelSource, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
35
48
  if (!prompt || typeof prompt !== 'string') {
36
49
  throw new Error('gemini-cli backend: prompt must be a non-empty string');
37
50
  }
@@ -43,8 +56,8 @@ export async function invoke({ prompt, model, signal, timeout = DEFAULT_BACKEND_
43
56
  // --skip-trust is required because the temp dir isn't in gemini's trusted
44
57
  // workspace list (otherwise gemini exits 55).
45
58
  const dir = mkdtempSync(join(tmpdir(), 'patina-gemini-'));
46
- const args = ['-p', '', '--output-format', 'text', '--skip-trust'];
47
- if (model) args.push('-m', model);
59
+ const cliModel = resolveLocalCliModel({ backendName: name, model, modelSource });
60
+ const args = ['-p', '', '--output-format', 'text', '--skip-trust', '-m', cliModel];
48
61
 
49
62
  return new Promise((resolve, reject) => {
50
63
  const proc = spawn('gemini', args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: dir });
@@ -2,12 +2,18 @@ import { callLLM } from '../api.js';
2
2
  import * as codexCli from './codex-cli.js';
3
3
  import * as claudeCli from './claude-cli.js';
4
4
  import * as geminiCli from './gemini-cli.js';
5
+ import * as kimiCli from './kimi-cli.js';
5
6
  import { inspectHttpApiKeySource } from '../auth.js';
6
7
  import { inputError } from '../errors.js';
8
+ import { DEFAULT_BEST_MODELS } from '../model-defaults.js';
7
9
  import {
8
10
  DEFAULT_BACKEND_TIMEOUT_MS,
9
11
  describeBackendError,
12
+ getBackendSafety,
10
13
  isRetryableBackendError,
14
+ resolveBackendMaxConcurrency,
15
+ resolveBackendMaxRetries,
16
+ withBackendConcurrencySlot,
11
17
  } from './contract.js';
12
18
 
13
19
  const openaiHttp = {
@@ -22,12 +28,12 @@ const openaiHttp = {
22
28
  model,
23
29
  signal,
24
30
  timeout = DEFAULT_BACKEND_TIMEOUT_MS,
31
+ maxRetries,
25
32
  temperature,
26
33
  seed,
27
34
  onResponse,
28
- cache,
29
35
  }) =>
30
- callLLM({ prompt, apiKey, baseURL, model, signal, timeout, temperature, seed, onResponse, cache }),
36
+ callLLM({ prompt, apiKey, baseURL, model, signal, timeout, maxRetries, temperature, seed, onResponse }),
31
37
  };
32
38
 
33
39
  const REGISTRY = {
@@ -35,16 +41,57 @@ const REGISTRY = {
35
41
  'codex-cli': codexCli,
36
42
  'claude-cli': claudeCli,
37
43
  'gemini-cli': geminiCli,
44
+ 'kimi-cli': kimiCli,
45
+ };
46
+
47
+ const BACKEND_META = {
48
+ 'openai-http': {
49
+ kind: 'http',
50
+ selectWith: 'default, --backend openai-http, --provider <name>',
51
+ defaultModel: DEFAULT_BEST_MODELS.openai,
52
+ },
53
+ 'codex-cli': {
54
+ kind: 'local-cli',
55
+ selectWith: '--backend codex-cli, --model codex-*',
56
+ defaultModel: DEFAULT_BEST_MODELS.codexCli,
57
+ },
58
+ 'claude-cli': {
59
+ kind: 'local-cli',
60
+ selectWith: '--backend claude-cli, --model claude-*',
61
+ defaultModel: DEFAULT_BEST_MODELS.claudeCli,
62
+ },
63
+ 'gemini-cli': {
64
+ kind: 'local-cli',
65
+ selectWith: '--backend gemini-cli, --model gemini-*',
66
+ defaultModel: DEFAULT_BEST_MODELS.geminiCli,
67
+ },
68
+ 'kimi-cli': {
69
+ kind: 'local-cli',
70
+ selectWith: '--backend kimi-cli, --model kimi-*',
71
+ defaultModel: DEFAULT_BEST_MODELS.kimiCli,
72
+ },
38
73
  };
39
74
 
40
75
  export function listBackends() {
41
76
  return Object.keys(REGISTRY).map((key) => {
42
77
  const b = REGISTRY[key];
78
+ const meta = BACKEND_META[key] || { kind: 'unknown', selectWith: `--backend ${key}` };
79
+ const safety = getBackendSafety(key);
43
80
  return {
44
81
  name: key,
82
+ kind: meta.kind,
83
+ selectWith: meta.selectWith,
84
+ defaultModel: meta.defaultModel || null,
85
+ safety,
86
+ maxConcurrency: safety.maxConcurrency,
87
+ maxRetries: safety.maxRetries,
88
+ promptMode: safety.promptMode,
89
+ agentRuntime: safety.agentRuntime,
45
90
  available: b.isAvailable(),
46
91
  authenticated: b.isAuthenticated(),
47
92
  authHint: b.authHint(),
93
+ loginCommand: b.loginCommand || null,
94
+ installHint: b.installHint || null,
48
95
  };
49
96
  });
50
97
  }
@@ -53,21 +100,26 @@ export function listBackendNames() {
53
100
  return Object.keys(REGISTRY);
54
101
  }
55
102
 
56
- export function selectBackend({ name, model } = {}) {
103
+ export function selectBackend({ name, model, modelSource } = {}) {
57
104
  if (name) {
58
105
  const backend = resolveBackend(name);
59
106
  return { backend, autoSelected: false, reason: 'explicit' };
60
107
  }
61
108
 
62
- if (model && /^codex(-|$)/i.test(model)) {
109
+ const useModelHeuristic = model && (modelSource === undefined || modelSource === 'flag');
110
+
111
+ if (useModelHeuristic && /^codex(-|$)/i.test(model)) {
63
112
  return { backend: REGISTRY['codex-cli'], autoSelected: false, reason: 'model heuristic' };
64
113
  }
65
- if (model && /^claude(-|$)/i.test(model)) {
114
+ if (useModelHeuristic && /^claude(-|$)/i.test(model)) {
66
115
  return { backend: REGISTRY['claude-cli'], autoSelected: false, reason: 'model heuristic' };
67
116
  }
68
- if (model && /^gemini(-|$)/i.test(model)) {
117
+ if (useModelHeuristic && /^gemini(-|$)/i.test(model)) {
69
118
  return { backend: REGISTRY['gemini-cli'], autoSelected: false, reason: 'model heuristic' };
70
119
  }
120
+ if (useModelHeuristic && /^kimi(-|$)/i.test(model)) {
121
+ return { backend: REGISTRY['kimi-cli'], autoSelected: false, reason: 'model heuristic' };
122
+ }
71
123
 
72
124
  // No silent auto-fallback to any CLI backend. Sending arbitrary text to a
73
125
  // coding agent is a higher-trust action than calling a plain completion
@@ -76,7 +128,7 @@ export function selectBackend({ name, model } = {}) {
76
128
  return { backend: REGISTRY['openai-http'], autoSelected: false, reason: 'default' };
77
129
  }
78
130
 
79
- export function selectBackendChain({ name, model } = {}) {
131
+ export function selectBackendChain({ name, model, modelSource } = {}) {
80
132
  if (name) {
81
133
  const names = String(name)
82
134
  .split(',')
@@ -96,7 +148,7 @@ export function selectBackendChain({ name, model } = {}) {
96
148
  };
97
149
  }
98
150
 
99
- const selected = selectBackend({ model });
151
+ const selected = selectBackend({ model, modelSource });
100
152
  return {
101
153
  backends: [selected.backend],
102
154
  autoSelected: selected.autoSelected,
@@ -110,27 +162,50 @@ export async function invokeBackendChain({
110
162
  apiKey,
111
163
  baseURL,
112
164
  model,
165
+ modelSource,
113
166
  signal,
114
167
  timeout = DEFAULT_BACKEND_TIMEOUT_MS,
168
+ maxConcurrency,
169
+ maxRetries,
115
170
  temperature,
116
171
  seed,
117
172
  onResponse,
118
- cache,
119
173
  logger,
120
174
  }) {
121
175
  if (!Array.isArray(backends) || backends.length === 0) {
122
176
  throw inputError(
123
177
  'no backend selected',
124
178
  'patina could not resolve a backend to run.',
125
- 'Pass --backend openai-http, codex-cli, claude-cli, or gemini-cli.'
179
+ 'Pass --backend openai-http, codex-cli, claude-cli, gemini-cli, or kimi-cli.'
126
180
  );
127
181
  }
128
182
 
129
183
  let lastError = null;
130
184
  for (let attemptIndex = 0; attemptIndex < backends.length; attemptIndex++) {
131
185
  const backend = backends[attemptIndex];
186
+ const effectiveMaxConcurrency = resolveBackendMaxConcurrency(backend.name, maxConcurrency);
187
+ const effectiveMaxRetries = resolveBackendMaxRetries(backend.name, maxRetries);
132
188
  try {
133
- return await backend.invoke({ prompt, apiKey, baseURL, model, signal, timeout, temperature, seed, onResponse, cache });
189
+ return await withBackendConcurrencySlot({
190
+ backendName: backend.name,
191
+ maxConcurrency: effectiveMaxConcurrency,
192
+ signal,
193
+ timeout,
194
+ fn: () => backend.invoke({
195
+ prompt,
196
+ apiKey,
197
+ baseURL,
198
+ model,
199
+ modelSource,
200
+ signal,
201
+ timeout,
202
+ maxRetries: effectiveMaxRetries,
203
+ temperature,
204
+ seed,
205
+ onResponse,
206
+ logger,
207
+ }),
208
+ });
134
209
  } catch (err) {
135
210
  lastError = err;
136
211
  const next = backends[attemptIndex + 1];
@@ -146,7 +221,7 @@ export async function invokeBackendChain({
146
221
  throw lastError || new Error('backend fallback chain failed without an error');
147
222
  }
148
223
 
149
- function resolveBackend(name) {
224
+ export function resolveBackend(name) {
150
225
  const backend = REGISTRY[name];
151
226
  if (!backend) {
152
227
  throw inputError(