aptly 0.1.1 → 0.1.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.
package/README.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
7
  [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
8
 
9
- **Model and reasoning recommendations for subagents.**
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
10
 
11
11
  [한국어](README.md) · **English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
12
12
 
@@ -20,15 +20,17 @@ Our own tests showed **about 52–58% lower API-equivalent costs**. See the [tes
20
20
 
21
21
  ## Quick start
22
22
 
23
- Requires Node.js **24 or later** and Claude Code or Codex. Use the commands below **after 0.1.1 is published on npm**.
23
+ Requires Node.js **24 or later** and Claude Code or Codex. Install the latest stable version globally with these commands.
24
24
 
25
25
  ```sh
26
- npm install -g aptly@0.1.1
26
+ npm install -g aptly@latest --allow-scripts=aptly
27
27
  aptly setup --client all --mode defaults
28
28
  aptly --version
29
29
  ```
30
30
 
31
- The version command should print `0.1.1`.
31
+ Check the installed version with `aptly --version`. On supported npm versions, `--allow-scripts=aptly` allows Aptly’s installation script. If your npm does not support this option, follow the explicit installation and setup steps in the [installation guide](docs/guide/en.md#installation).
32
+
33
+ Before upgrading, stop all related host, MCP, and CLI processes and review the ledger transition in the [installation and upgrade guide](docs/guide/en.md#installation).
32
34
 
33
35
  During installation, `postinstall` registers the MCP server and a `PreToolUse` Hook in existing host configuration directories. Run `setup` explicitly if automatic registration was skipped or a host has no configuration directory yet. Other settings and Hooks, and any existing Aptly launch command and environment, are preserved.
34
36
 
package/README.ja.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
7
  [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
8
 
9
- **Model and reasoning recommendations for subagents.**
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
10
 
11
11
  [한국어](README.md) · [English](README.en.md) · [简体中文](README.zh-CN.md) · **日本語**
12
12
 
@@ -20,15 +20,17 @@ Aptly は必要最小限のタスク要約を OpenAI Decisions API で分類し
20
20
 
21
21
  ## クイックスタート
22
22
 
23
- Node.js **24 以上**と Claude Code または Codex が必要です。以下のコマンドは **0.1.1 が npm で公開された後**に使用してください。
23
+ Node.js **24 以上**と Claude Code または Codex が必要です。以下のコマンドで最新の安定版をグローバルインストールしてください。
24
24
 
25
25
  ```sh
26
- npm install -g aptly@0.1.1
26
+ npm install -g aptly@latest --allow-scripts=aptly
27
27
  aptly setup --client all --mode defaults
28
28
  aptly --version
29
29
  ```
30
30
 
31
- バージョン確認コマンドの出力が `0.1.1` であることを確認してください。
31
+ `aptly --version` でインストール済みのバージョンを確認してください。対応する npm では、`--allow-scripts=aptly` で Aptly のインストールスクリプトを許可します。このオプションに対応していない npm では、[インストール案内](docs/guide/ja.md#インストール)の明示的なインストール・設定手順に従ってください。
32
+
33
+ アップグレード前に、関連するホスト・MCP・CLI の全プロセスを停止し、[インストールとアップグレードの案内](docs/guide/ja.md#インストール)で台帳の移行手順を確認してください。
32
34
 
33
35
  インストール時の `postinstall` は、既存のホスト設定ディレクトリに MCP と `PreToolUse` Hook を登録します。自動登録をスキップした場合や、ホストに設定ディレクトリがまだない場合も、`setup` を明示的に実行して登録できます。他の既存設定・Hook と、既存の Aptly の実行コマンド・環境は保持します。
34
36
 
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
7
  [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
8
 
9
- **Model and reasoning recommendations for subagents.**
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
10
 
11
11
  **한국어** · [English](README.en.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
12
12
 
@@ -20,15 +20,17 @@ Aptly는 최소한의 작업 요약을 OpenAI Decisions API로 분류하고 허
20
20
 
21
21
  ## 빠른 시작
22
22
 
23
- Node.js **24 이상**과 Claude Code 또는 Codex가 필요합니다. 아래 명령은 **0.1.1이 npm에 게시된 후** 사용하십시오.
23
+ Node.js **24 이상**과 Claude Code 또는 Codex가 필요합니다. 다음 명령으로 최신 안정 버전을 전역 설치하십시오.
24
24
 
25
25
  ```sh
26
- npm install -g aptly@0.1.1
26
+ npm install -g aptly@latest --allow-scripts=aptly
27
27
  aptly setup --client all --mode defaults
28
28
  aptly --version
29
29
  ```
30
30
 
31
- 버전 확인 명령의 출력은 `0.1.1`입니다.
31
+ `aptly --version`으로 설치된 버전을 확인하십시오. `--allow-scripts=aptly`는 지원하는 npm에서 Aptly의 설치 스크립트를 허용합니다. 이 옵션을 지원하지 않는 npm은 [설치 안내](docs/guide/ko.md#설치)의 명시적 설치·설정 절차를 따르십시오.
32
+
33
+ 업그레이드 전에는 관련 호스트·MCP·CLI 프로세스를 모두 중지하고 [설치·업그레이드 안내](docs/guide/ko.md#설치)의 원장 전환 절차를 확인하십시오.
32
34
 
33
35
  설치 시 `postinstall`은 기존 호스트 설정 디렉터리에 MCP와 `PreToolUse` Hook을 등록합니다. 명시적인 `setup`은 자동 등록을 건너뛰었거나 아직 설정 디렉터리가 없는 호스트에도 사용할 수 있습니다. 기존 다른 설정·Hook과 기존 Aptly 실행 명령·환경은 보존합니다.
34
36
 
package/README.zh-CN.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
7
  [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
8
 
9
- **Model and reasoning recommendations for subagents.**
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
10
 
11
11
  [한국어](README.md) · [English](README.en.md) · **简体中文** · [日本語](README.ja.md)
12
12
 
@@ -20,15 +20,17 @@ Aptly 使用 OpenAI Decisions API 对简要任务摘要进行分类,并推荐
20
20
 
21
21
  ## 快速开始
22
22
 
23
- 需要 Node.js **24 或更高版本**以及 Claude Code 或 Codex。请在 **0.1.1 发布到 npm 后**使用以下命令。
23
+ 需要 Node.js **24 或更高版本**以及 Claude Code 或 Codex。请使用以下命令全局安装最新稳定版。
24
24
 
25
25
  ```sh
26
- npm install -g aptly@0.1.1
26
+ npm install -g aptly@latest --allow-scripts=aptly
27
27
  aptly setup --client all --mode defaults
28
28
  aptly --version
29
29
  ```
30
30
 
31
- 版本检查命令应输出 `0.1.1`。
31
+ 请使用 `aptly --version` 确认已安装的版本。在支持此选项的 npm 中,`--allow-scripts=aptly` 允许执行 Aptly 的安装脚本。如果 npm 不支持此选项,请按照[安装指南](docs/guide/zh-CN.md#安装)中的显式安装与配置步骤操作。
32
+
33
+ 升级前,请停止所有相关宿主、MCP 和 CLI 进程,并查看[安装与升级指南](docs/guide/zh-CN.md#安装)中的账本迁移步骤。
32
34
 
33
35
  安装时的 `postinstall` 会在现有宿主配置目录中注册 MCP 和 `PreToolUse` Hook。如果跳过了自动注册,或宿主尚无配置目录,也可以显式运行 `setup`。其他现有配置与 Hook,以及 Aptly 原有的启动命令和环境均会保留。
34
36
 
package/dist/bin/aptly.js CHANGED
@@ -9,14 +9,29 @@ import { collectDiagnostics } from '../src/diagnostics.js';
9
9
  import { exportDiagnosticReport } from '../src/report.js';
10
10
  import { fetchModelInfo, saveModelInfo } from '../src/model-info.js';
11
11
  import { errorCode } from '../src/types.js';
12
- import { setupHosts, removeSetup, setDefaults, setEnvironment, configurableEnvironmentKeys } from '../src/setup.js';
12
+ import { setupHosts, removeSetup, setDefaults, setEnvironment, configurableEnvironmentKeys, migrateLauncher } from '../src/setup.js';
13
13
  import { readEnvironmentValue } from '../src/cli-input.js';
14
- import { setApiKey } from '../src/credential-setup.js';
14
+ import { prepareApiKey } from '../src/credential-setup.js';
15
+ import { updateGlobalInstallation } from '../src/update.js';
16
+ import { updateNotification } from '../src/update-notifier.js';
15
17
  const [command = 'serve', ...args] = process.argv.slice(2);
16
18
  const output = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
17
19
  try {
18
20
  if (['--help', '-h', 'help'].includes(command)) {
19
- process.stdout.write('Aptly (preview)\n\nCommands:\n serve Start the stdio MCP server (default: fixture/shadow).\n capabilities Print the active catalog; no API call.\n catalog Print the active catalog; no API call.\n catalog refresh --output ABS_PATH Fetch official model descriptions to a private cache.\n recommend Recommend for a task JSON on stdin; no host evidence required.\n inspect CLIENT FILE Inspect an explicit JSON settings inventory; no API call.\n config CLIENT Print an MCP registration fragment; does not edit settings.\n set default Add missing default MCP environment settings.\n set env ENV_KEY Prompt for an MCP environment value.\n setup Register MCP and automatic PreToolUse Hooks.\n remove Remove only unchanged Aptly-owned registration.\n report Write redacted local diagnostic attachments (default: md + json).\n --format md|json|both Select report formats.\n --output-dir DIR Private output directory (default: reports).\n --version Print the package version.\n\nUse aptly set env OPENAI_API_KEY for OS storage, or pass it through the MCP environment.\nPaid mode additionally requires\nROUTER_PROVIDER=decisions and an absolute ROUTER_LEDGER_PATH in a private directory.\nROUTER_CONFIG_PATH is an optional private operator configuration file.\n');
21
+ process.stdout.write('Aptly (preview)\n\nCommands:\n serve Start the stdio MCP server (default: fixture/shadow).\n capabilities Print the active catalog; no API call.\n catalog Print the active catalog; no API call.\n catalog refresh --output ABS_PATH Fetch official model descriptions to a private cache.\n recommend Recommend for a task JSON on stdin; no host evidence required.\n inspect CLIENT FILE Inspect an explicit JSON settings inventory; no API call.\n config CLIENT Print an MCP registration fragment; does not edit settings.\n set default Add missing default MCP environment settings.\n set env ENV_KEY Prompt for an MCP environment value.\n setup Register MCP and automatic PreToolUse Hooks.\n remove Remove only unchanged Aptly-owned registration.\n update Update this verified global npm installation.\n report Write redacted local diagnostic attachments (default: md + json).\n --format md|json|both Select report formats.\n --output-dir DIR Private output directory (default: reports).\n --version Print the package version.\n\nUse aptly set env OPENAI_API_KEY for OS storage, or pass it through the MCP environment.\nPaid mode additionally requires\nROUTER_PROVIDER=decisions and an absolute ROUTER_LEDGER_PATH in a private directory.\nROUTER_CONFIG_PATH is an optional private operator configuration file.\n');
22
+ }
23
+ else if (command === 'update') {
24
+ if (args.length === 1 && args[0] === '--help')
25
+ process.stdout.write('Usage: aptly update\nRequires a verified global npm installation. Stop other hosts/MCP/CLI writers first.\nUpdates the same npm prefix without lifecycle setup. Run aptly setup and restart hosts afterward; existing launchers are preserved.\n');
26
+ else {
27
+ if (args.length)
28
+ throw new Error('update_invalid_options');
29
+ const packageRoot = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../' : '../../', import.meta.url));
30
+ const result = updateGlobalInstallation({ packageRoot });
31
+ output(result);
32
+ if (result.status === 'error')
33
+ process.exitCode = 1;
34
+ }
20
35
  }
21
36
  else if (command === '--version')
22
37
  process.stdout.write(VERSION + '\n');
@@ -39,7 +54,7 @@ try {
39
54
  }
40
55
  else if (command === 'set') {
41
56
  if (args.length === 1 && args[0] === '--help')
42
- process.stdout.write('Usage: aptly set default [--client codex|claude|all]\n aptly set env ENV_KEY [--client codex|claude|all] [--stdin]\nValues are entered interactively with hidden input; --stdin explicitly reads a value from standard input. Existing defaults and launchers are preserved. OPENAI_API_KEY is stored in the OS credential store; host files contain references only. Restart MCP after changes.\n');
57
+ process.stdout.write('Usage: aptly set default [--client codex|claude|all]\n aptly set env ENV_KEY [--client codex|claude|all] [--stdin | --check]\n--check diagnoses OPENAI_API_KEY registration without reading or storing a key.\nValues are entered interactively with hidden input; --stdin explicitly reads a value from standard input. Existing defaults and launchers are preserved. OPENAI_API_KEY is stored in the OS credential store; host files contain references only. Restart MCP after changes.\n');
43
58
  else {
44
59
  const action = args[0];
45
60
  let key;
@@ -54,12 +69,17 @@ try {
54
69
  throw new Error('set_invalid_options');
55
70
  let clients = ['codex', 'claude'];
56
71
  let fromStdin = false;
72
+ let checkOnly = false;
57
73
  const seen = new Set();
58
74
  for (let i = start; i < args.length; i++) {
59
75
  const flag = args[i];
60
76
  if (seen.has(flag))
61
77
  throw new Error('set_invalid_options');
62
78
  seen.add(flag);
79
+ if (flag === '--check' && action === 'env' && key === 'OPENAI_API_KEY') {
80
+ checkOnly = true;
81
+ continue;
82
+ }
63
83
  if (flag === '--stdin' && action === 'env') {
64
84
  fromStdin = true;
65
85
  continue;
@@ -72,33 +92,57 @@ try {
72
92
  throw new Error('set_invalid_options');
73
93
  }
74
94
  const packageRoot = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../' : '../../', import.meta.url));
95
+ if (checkOnly && fromStdin)
96
+ throw new Error('set_invalid_options');
97
+ const credential = key === 'OPENAI_API_KEY' ? prepareApiKey({ clients, packageRoot }) : undefined;
75
98
  let value = '';
76
- try {
77
- if (action === 'env')
78
- value = await readEnvironmentValue({ key: key, fromStdin });
79
- const result = action === 'default' ? setDefaults({ clients, packageRoot }) : key === 'OPENAI_API_KEY' ? setApiKey(value, { clients, packageRoot }) : setEnvironment(key, value, { clients, packageRoot });
80
- output(result);
81
- if (result.status === 'partial')
99
+ if (credential && (checkOnly || credential.result.status === 'partial')) {
100
+ output(credential.result);
101
+ if (credential.result.status === 'partial')
82
102
  process.exitCode = 1;
83
103
  }
84
- finally {
85
- value = '';
86
- }
104
+ else
105
+ try {
106
+ if (action === 'env')
107
+ value = await readEnvironmentValue({ key: key, fromStdin });
108
+ const result = action === 'default' ? setDefaults({ clients, packageRoot }) : key === 'OPENAI_API_KEY' ? credential.apply(value) : setEnvironment(key, value, { clients, packageRoot });
109
+ output(result);
110
+ if (result.status === 'partial')
111
+ process.exitCode = 1;
112
+ }
113
+ finally {
114
+ value = '';
115
+ }
87
116
  }
88
117
  }
89
118
  else if (['setup', 'remove'].includes(command)) {
90
119
  if (args.length === 1 && args[0] === '--help')
91
- process.stdout.write('Usage: aptly setup [--client codex|claude|all] [--mode defaults|advisory] [--automatic]\n aptly remove [--client codex|claude|all]\nSetup merges MCP and PreToolUse registration. Codex requires native /hooks trust review.\nAutomatic setup skips CI, APTLY_SKIP_SETUP=1, and absent host directories. No keys are stored.\n');
120
+ process.stdout.write('Usage: aptly setup [--client codex|claude|all] [--mode defaults|advisory] [--automatic]\n aptly setup --client codex|claude --migrate-launcher [--include-existing] [--dry-run]\n aptly remove [--client codex|claude|all]\nSetup merges MCP and PreToolUse registration. Codex requires native /hooks trust review.\nAutomatic setup skips CI, APTLY_SKIP_SETUP=1, and absent host directories. No keys are stored.\n');
92
121
  else {
93
122
  let clients = ['codex', 'claude'];
94
123
  let mode = 'defaults';
95
124
  let automatic = false;
125
+ let migrate = false;
126
+ let dryRun = false;
127
+ let includeExisting = false;
96
128
  const seen = new Set();
97
129
  for (let i = 0; i < args.length; i++) {
98
130
  const flag = args[i];
99
131
  if (seen.has(flag))
100
132
  throw new Error('setup_invalid_options');
101
133
  seen.add(flag);
134
+ if (flag === '--migrate-launcher' && command === 'setup') {
135
+ migrate = true;
136
+ continue;
137
+ }
138
+ if (flag === '--include-existing' && command === 'setup') {
139
+ includeExisting = true;
140
+ continue;
141
+ }
142
+ if (flag === '--dry-run' && command === 'setup') {
143
+ dryRun = true;
144
+ continue;
145
+ }
102
146
  if (flag === '--automatic' && command === 'setup') {
103
147
  automatic = true;
104
148
  continue;
@@ -112,7 +156,9 @@ try {
112
156
  throw new Error('setup_invalid_options');
113
157
  }
114
158
  const packageRoot = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../' : '../../', import.meta.url));
115
- const result = command === 'setup' ? setupHosts({ clients, mode, automatic, packageRoot }) : removeSetup({ clients });
159
+ if ((migrate && (clients.length !== 1 || automatic || seen.has('--mode'))) || ((dryRun || includeExisting) && !migrate))
160
+ throw new Error('setup_invalid_options');
161
+ const result = migrate ? migrateLauncher({ clients, packageRoot }, dryRun, includeExisting) : command === 'setup' ? setupHosts({ clients, mode, automatic, packageRoot }) : removeSetup({ clients });
116
162
  output(result);
117
163
  if (result.status === 'partial' && !automatic)
118
164
  process.exitCode = 1;
@@ -168,7 +214,17 @@ catch (error) {
168
214
  const message = error instanceof Error ? error.message : undefined;
169
215
  const configCode = [caughtCode, message].find(value => typeof value === 'string' && safeConfigurationCodes.includes(value));
170
216
  const setCodes = ['set_environment_key_unsupported', 'set_invalid_options', 'set_value_too_large', 'set_interactive_input_required', 'set_input_cancelled'];
171
- const code = command === 'set' ? (setCodes.includes(message ?? '') ? message : 'set_failed') : command === 'report' ? (caughtCode && codes.includes(caughtCode) ? caughtCode : 'report_failed') : configCode ?? 'operation_failed';
217
+ const code = command === 'update' ? (message === 'update_invalid_options' ? message : 'update_failed') : command === 'set' ? (setCodes.includes(message ?? '') ? message : 'set_failed') : command === 'report' ? (caughtCode && codes.includes(caughtCode) ? caughtCode : 'report_failed') : configCode ?? 'operation_failed';
172
218
  process.stderr.write('aptly: ' + code + '\n');
173
219
  process.exitCode = 1;
174
220
  }
221
+ finally {
222
+ if (!process.exitCode) {
223
+ try {
224
+ const hint = await updateNotification({ command, args, env: process.env, stdinIsTTY: !!process.stdin.isTTY, stderrIsTTY: !!process.stderr.isTTY, stdoutIsTTY: !!process.stdout.isTTY, currentVersion: VERSION });
225
+ if (hint)
226
+ process.stderr.write(hint + '\n');
227
+ }
228
+ catch { /* Optional update hints cannot fail a successful command. */ }
229
+ }
230
+ }
@@ -23,6 +23,7 @@ export declare class Router {
23
23
  ledgerPath: string | undefined;
24
24
  state: LedgerState;
25
25
  active: number;
26
+ private heldLedgerLock;
26
27
  constructor({ policy, provider, ledgerPath }?: RouterOptions);
27
28
  capabilities(): {
28
29
  policy_version: string;
package/dist/src/core.js CHANGED
@@ -1,7 +1,8 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, lstatSync, chmodSync } from 'node:fs';
3
3
  import { isRecord, errorCode } from './types.js';
4
4
  import { builtInProfiles, resolveCatalog, isSupportedProfile } from './catalog.js';
5
+ import { acquireLedgerLock } from './ledger-lock.js';
5
6
  const defaultCatalog = resolveCatalog({});
6
7
  export const defaultPolicy = Object.freeze({
7
8
  version: 'aptly-v010', catalog_version: defaultCatalog.version, max_summary_bytes: 4000,
@@ -66,6 +67,7 @@ export class Router {
66
67
  ledgerPath;
67
68
  state;
68
69
  active;
70
+ heldLedgerLock;
69
71
  constructor({ policy = defaultPolicy, provider, ledgerPath } = {}) {
70
72
  this.policy = clone(policy);
71
73
  const integerCaps = { max_summary_bytes: 64000, max_concurrency: 16, max_daily_calls: 100000, max_total_calls: 1000000, timeout_ms: 60000 };
@@ -123,6 +125,7 @@ export class Router {
123
125
  load() {
124
126
  if (!this.ledgerPath)
125
127
  return;
128
+ this.heldLedgerLock?.assertHeld();
126
129
  try {
127
130
  const stat = lstatSync(this.ledgerPath);
128
131
  if (!stat.isFile() || stat.isSymbolicLink())
@@ -158,13 +161,15 @@ export class Router {
158
161
  save() {
159
162
  if (!this.ledgerPath)
160
163
  return;
161
- const tmp = `${this.ledgerPath}.write-${process.pid}`;
164
+ this.heldLedgerLock?.assertHeld();
165
+ const tmp = `${this.ledgerPath}.write-${randomUUID()}`;
162
166
  let fd;
163
167
  try {
164
168
  fd = openSync(tmp, 'wx', 0o600);
165
169
  writeFileSync(fd, JSON.stringify(this.state));
166
170
  closeSync(fd);
167
171
  fd = undefined;
172
+ this.heldLedgerLock?.assertHeld();
168
173
  renameSync(tmp, this.ledgerPath);
169
174
  }
170
175
  finally {
@@ -228,12 +233,13 @@ export class Router {
228
233
  let lock;
229
234
  if (this.ledgerPath) {
230
235
  try {
231
- lock = openSync(`${this.ledgerPath}.lock`, 'wx', 0o600);
236
+ lock = acquireLedgerLock(this.ledgerPath);
232
237
  }
233
- catch {
234
- return this.result('unavailable', 'ledger_locked');
238
+ catch (error) {
239
+ return this.result('unavailable', errorCode(error) === 'ledger_locked' ? 'ledger_locked' : 'ledger_error');
235
240
  }
236
241
  }
242
+ this.heldLedgerLock = lock;
237
243
  this.active++;
238
244
  try {
239
245
  this.load();
@@ -308,7 +314,7 @@ export class Router {
308
314
  }
309
315
  catch (e) {
310
316
  // A failed transport can have consumed a paid call. Preserve its unknown marker.
311
- const allowed = ['timeout', 'refused', 'truncated', 'invalid_json', 'unauthorized', 'rate_limited', 'network'];
317
+ const allowed = ['timeout', 'refused', 'truncated', 'invalid_json', 'unauthorized', 'rate_limited', 'network', 'ledger_error'];
312
318
  const code = errorCode(e);
313
319
  return this.result('unavailable', code !== undefined && allowed.includes(code) ? code : 'provider_error', { latency_ms: latency() });
314
320
  }
@@ -321,12 +327,12 @@ export class Router {
321
327
  }
322
328
  finally {
323
329
  this.active--;
330
+ this.heldLedgerLock = undefined;
324
331
  if (lock !== undefined) {
325
- closeSync(lock);
326
332
  try {
327
- unlinkSync(`${this.ledgerPath}.lock`);
333
+ lock.release();
328
334
  }
329
- catch { }
335
+ catch { /* Never unlink a persistent OS-owned gate. */ }
330
336
  }
331
337
  }
332
338
  }
@@ -11,4 +11,8 @@ export interface CredentialBackend {
11
11
  }): void;
12
12
  }
13
13
  /** Key bytes enter the OS store only; host files receive nonsecret references. */
14
+ export declare function prepareApiKey(options?: SetupOptions): {
15
+ result: SetupResult;
16
+ apply(value: string, backend?: CredentialBackend): SetupResult;
17
+ };
14
18
  export declare function setApiKey(value: string, options?: SetupOptions, backend?: CredentialBackend): SetupResult;
@@ -1,7 +1,18 @@
1
1
  import { loadSecret, storeSecret, deleteSecret } from './secret-store.js';
2
- import { setCredentialReference } from './setup.js';
2
+ import { prepareCredentialReference } from './setup.js';
3
3
  /** Restore once, then verify even if the restore's acknowledgement was lost. */
4
- function restore(previous, account, backend) {
4
+ function restore(previous, attemptedValue, account, backend) {
5
+ // Do not overwrite an observed concurrent credential change or an unreadable value.
6
+ try {
7
+ const current = backend.loadSecret({ account });
8
+ if (current === previous)
9
+ return true;
10
+ if (current !== attemptedValue)
11
+ return false;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
5
16
  try {
6
17
  if (previous === null)
7
18
  backend.deleteSecret({ account });
@@ -17,40 +28,62 @@ function restore(previous, account, backend) {
17
28
  }
18
29
  }
19
30
  /** Key bytes enter the OS store only; host files receive nonsecret references. */
20
- export function setApiKey(value, options = {}, backend = { loadSecret, storeSecret, deleteSecret }) {
31
+ export function prepareApiKey(options = {}) {
21
32
  const clients = [...new Set(options.clients ?? ['codex', 'claude'])];
22
33
  if (clients.some(c => c !== 'codex' && c !== 'claude'))
23
- return { status: 'partial', clients: [] };
24
- if (!/^sk-[A-Za-z0-9_-]{12,}$/.test(value) || Buffer.byteLength(value) > 8192)
25
- return { status: 'partial', clients: clients.map(client => ({ client, status: 'error', codes: ['credential_value_invalid'] })) };
26
- const results = [];
27
- for (const client of clients) {
28
- const account = `${client}:OPENAI_API_KEY`;
29
- let previous = null, attempted = false;
30
- try {
31
- previous = backend.loadSecret({ account });
32
- // A timeout/process/output error may occur after the OS committed a write.
33
- attempted = true;
34
- backend.storeSecret(value, { account });
35
- const configured = setCredentialReference({ ...options, clients: [client] });
36
- const result = configured.clients[0];
37
- if (!result || result.status === 'error' || result.status === 'skipped') {
38
- const recovered = restore(previous, account, backend);
39
- const failed = result ?? { client, status: 'error', codes: ['credential_configuration_failed'] };
40
- results.push(recovered ? failed : { ...failed, status: 'error', codes: [...failed.codes, 'credential_rollback_failed'] });
34
+ throw new Error('set_invalid_options');
35
+ const plans = clients.map(client => prepareCredentialReference(options, client));
36
+ const result = { status: plans.some(p => p.result.status === 'error') ? 'partial' : 'complete', clients: plans.map(p => p.result) };
37
+ let used = false;
38
+ return { result, apply(value, backend = { loadSecret, storeSecret, deleteSecret }) {
39
+ if (used)
40
+ return { status: 'partial', clients: clients.map(client => ({ client, status: 'error', codes: ['credential_plan_consumed'] })) };
41
+ used = true;
42
+ if (!/^sk-[A-Za-z0-9_-]{12,}$/.test(value) || Buffer.byteLength(value) > 8192)
43
+ return { status: 'partial', clients: clients.map(client => ({ client, status: 'error', codes: ['credential_value_invalid'] })) };
44
+ const results = [];
45
+ for (const plan of plans) {
46
+ const client = plan.result.client;
47
+ const checked = plan.check();
48
+ if (checked.status === 'error') {
49
+ results.push(checked);
50
+ continue;
51
+ }
52
+ const account = `${client}:OPENAI_API_KEY`;
53
+ let previous = null, attempted = false;
54
+ try {
55
+ previous = backend.loadSecret({ account });
56
+ const rechecked = plan.check();
57
+ if (rechecked.status === 'error') {
58
+ results.push(rechecked);
59
+ continue;
60
+ }
61
+ // A timeout/process/output error may occur after the OS committed a write.
62
+ attempted = true;
63
+ backend.storeSecret(value, { account });
64
+ const result = plan.commit();
65
+ if (!result || result.status === 'error' || result.status === 'skipped') {
66
+ const recovered = restore(previous, value, account, backend);
67
+ const failed = result ?? { client, status: 'error', codes: ['credential_configuration_failed'] };
68
+ results.push(recovered ? failed : { ...failed, status: 'error', codes: [...failed.codes, 'credential_rollback_failed'] });
69
+ }
70
+ else
71
+ results.push({ ...result, codes: [...result.codes, 'credential_stored_in_os', 'mcp_restart_required'] });
72
+ }
73
+ catch (error) {
74
+ const recovered = !attempted || restore(previous, value, account, backend);
75
+ const allowed = ['secure_store_invalid_options', 'secure_store_invalid_value', 'secure_store_unavailable', 'secure_store_timeout', 'secure_store_failed', 'secure_store_cancelled', 'secure_store_denied', 'secure_store_locked', 'secure_store_unsupported_platform'];
76
+ const code = error instanceof Error && allowed.includes(error.message) ? error.message : 'credential_store_failed';
77
+ results.push({ client, status: 'error', codes: [code, ...(recovered ? [] : ['credential_rollback_failed'])] });
78
+ }
79
+ finally {
80
+ previous = null;
81
+ }
41
82
  }
42
- else
43
- results.push({ ...result, codes: [...result.codes, 'credential_stored_in_os', 'mcp_restart_required'] });
83
+ return { status: results.some(r => r.status === 'error') ? 'partial' : 'complete', clients: results };
44
84
  }
45
- catch (error) {
46
- const recovered = !attempted || restore(previous, account, backend);
47
- const allowed = ['secure_store_invalid_options', 'secure_store_invalid_value', 'secure_store_unavailable', 'secure_store_timeout', 'secure_store_failed', 'secure_store_cancelled', 'secure_store_denied', 'secure_store_locked', 'secure_store_unsupported_platform'];
48
- const code = error instanceof Error && allowed.includes(error.message) ? error.message : 'credential_store_failed';
49
- results.push({ client, status: 'error', codes: [code, ...(recovered ? [] : ['credential_rollback_failed'])] });
50
- }
51
- finally {
52
- previous = null;
53
- }
54
- }
55
- return { status: results.some(r => r.status === 'error') ? 'partial' : 'complete', clients: results };
85
+ };
86
+ }
87
+ export function setApiKey(value, options = {}, backend = { loadSecret, storeSecret, deleteSecret }) {
88
+ return prepareApiKey(options).apply(value, backend);
56
89
  }
@@ -0,0 +1,8 @@
1
+ export interface LedgerLock {
2
+ /** lstat-only while held: never open/read/close the canonical SQLite file. */
3
+ assertHeld(): void;
4
+ /** Roll back and close this connection. Never unlink the persistent gate. */
5
+ release(): void;
6
+ }
7
+ /** Local, hard-link-capable filesystems only; no PID or stale-age recovery. */
8
+ export declare function acquireLedgerLock(ledgerPath: string): LedgerLock;