aiterm-mcp 0.58.3 → 0.59.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.
@@ -3,7 +3,8 @@
3
3
  import { fileURLToPath } from "node:url";
4
4
  import * as steer from "aiterm-steer-delivery";
5
5
  import { cursorHookRoots } from "./cursor-parent-receiver.js";
6
- export function cursorReceiveProcess(deliveryId, executable = process.execPath) {
6
+ import { liveNodeExecutable } from "./live-node.js";
7
+ export function cursorReceiveProcess(deliveryId, executable = liveNodeExecutable()) {
7
8
  return steer.cursorReceiveProcess(fileURLToPath(import.meta.url), deliveryId, executable);
8
9
  }
9
10
  /** 背景processの起動情報を、親のshellへ書ける1行にする(Windowsの既定shellはPowerShell)。 */
@@ -0,0 +1,22 @@
1
+ // ファスト(高速の応答)の切り替えの、harness中立の部分(ADR 0114)。
2
+ // harnessごとの切り替え方は src/harnesses/ と core が持ち、ここは断りの符号と、起動時の扱いだけを持つ。
3
+ import { AitermError } from "./errors.js";
4
+ export class FastModeError extends AitermError {
5
+ fast_code;
6
+ constructor(code, detail) {
7
+ super(`${code}: ${detail}`, 2);
8
+ this.name = "FastModeError";
9
+ this.fast_code = code;
10
+ }
11
+ }
12
+ /**
13
+ * 起動時にfastへ出来なかった時、起動を断らずに通常の速度で起こす符号か。
14
+ * アカウントの都合(credits切れ等)や確かめられない時に起動まで断ると、呼び手の席が起きず、届いた文が落ちる。
15
+ * 設定の誤り(対応していないmodel、IDとの食い違い)は、起動の前に断る。
16
+ */
17
+ export function launchContinuesWithoutFast(code) {
18
+ return code === "FAST_MODE_UNAVAILABLE" || code === "FAST_MODE_UNCONFIRMED";
19
+ }
20
+ export function fastModeFailure(error) {
21
+ return error instanceof FastModeError ? { code: error.fast_code, message: error.message } : null;
22
+ }
@@ -11,7 +11,7 @@ import { AitermError } from "../errors.js";
11
11
  import { modeBitsWorldAccessible } from "../tmux-runtime.js";
12
12
  import { runAgentProtocolCommand, spawnAgentControlCommand } from "../agent-resolver.js";
13
13
  import { catalogInvalid, catalogUnavailable, checkedCatalog, findJsonLine, processSummary } from "../model-catalog.js";
14
- import { currentUid, writeJson0600, shq, subagentInstruction, writeScopeLaunchNote, agentEventPath, createEmpty0600, writeAgentMetadata, agentLineageFields, assertSessionName, agentsDir, LAUNCH_ID_RE, AGENT_EVENT_TAIL_BYTES, readFileRange, } from "../agent-shared.js";
14
+ import { currentUid, writeJson0600, shq, subagentInstruction, AGENT_DEFAULT_PREFACE, writeScopeLaunchNote, agentEventPath, createEmpty0600, writeAgentMetadata, agentLineageFields, assertSessionName, agentsDir, LAUNCH_ID_RE, AGENT_EVENT_TAIL_BYTES, readFileRange, } from "../agent-shared.js";
15
15
  export const OPERATION_ID_RE = /^sha256:[0-9a-f]{64}$/;
16
16
  export const CLAUDE_RESULT_MAX_BYTES = 4 * 1024 * 1024;
17
17
  export const CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
@@ -72,6 +72,9 @@ export function createClaudeCorrelationSettings(name, launchId, model, effort) {
72
72
  writeJson0600(settings, {
73
73
  ...(model ? { model } : {}),
74
74
  ...(effort ? { effortLevel: effort } : {}),
75
+ // 席は必ず通常の速度で始める(ADR 0114)。--settingsは利用者の設定より強いので、共有の設定にfastModeが残っていても効かない。
76
+ // ここへtrueは入れない: 後で/fast offにしても、機種を替えた時にClaude Codeがこの値を見てfastへ戻す。
77
+ fastMode: false,
75
78
  hooks: {
76
79
  Stop: [
77
80
  {
@@ -151,29 +154,70 @@ export async function claudeModelChoices(bin, cwd) {
151
154
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aiterm-claude-models-"));
152
155
  try {
153
156
  const settings = path.join(dir, "settings.json");
154
- fs.writeFileSync(settings, JSON.stringify({ disableAllHooks: true }), { mode: 0o600 });
155
- const requestId = `aiterm-models-${randomUUID()}`;
156
- const request = { type: "control_request", request_id: requestId, request: { subtype: "initialize" } };
157
- const result = await runAgentProtocolCommand(bin, [
158
- "-p", "--output-format", "stream-json", "--verbose", "--input-format", "stream-json",
159
- "--no-session-persistence", "--strict-mcp-config", "--settings", settings,
160
- ], {
161
- cwd,
162
- env: process.env,
163
- input: JSON.stringify(request) + "\n",
164
- until: requestId,
165
- timeout: CLAUDE_MODELS_TIMEOUT_MS,
166
- maxBuffer: CLAUDE_MODELS_MAX_BYTES,
167
- });
168
- if (result.error || result.status !== 0) {
169
- throw catalogUnavailable("Claude Code", result.error?.message || result.stderr?.trim() || `exit=${result.status ?? "unknown"}`);
157
+ // fastMode: true を付けて聞くと、応答の fast_mode_disabled_reason が「このアカウントで使えない訳」になる
158
+ // (付けないと sdk_opt_in_required が返るだけ)。promptは送らないので、fastで流れる番は無い。
159
+ fs.writeFileSync(settings, JSON.stringify({ disableAllHooks: true, fastMode: true }), { mode: 0o600 });
160
+ // Claude Codeは、起きてすぐは「fastの可否を確かめている最中(pending)」と答える事がある(その端末で初めて聞いた時。Windowsで見た)。
161
+ // 少し置いて聞き直すと、アカウントの答え(使える、または使えない訳)が返る。
162
+ for (let attempt = 0;; attempt++) {
163
+ const requestId = `aiterm-models-${randomUUID()}`;
164
+ const request = { type: "control_request", request_id: requestId, request: { subtype: "initialize" } };
165
+ const result = await runAgentProtocolCommand(bin, [
166
+ "-p", "--output-format", "stream-json", "--verbose", "--input-format", "stream-json",
167
+ "--no-session-persistence", "--strict-mcp-config", "--settings", settings,
168
+ ], {
169
+ cwd,
170
+ env: process.env,
171
+ input: JSON.stringify(request) + "\n",
172
+ until: requestId,
173
+ timeout: CLAUDE_MODELS_TIMEOUT_MS,
174
+ maxBuffer: CLAUDE_MODELS_MAX_BYTES,
175
+ });
176
+ if (result.error || result.status !== 0) {
177
+ throw catalogUnavailable("Claude Code", result.error?.message || result.stderr?.trim() || `exit=${result.status ?? "unknown"}`);
178
+ }
179
+ if (attempt < CLAUDE_FAST_PENDING_RETRIES && claudeFastStatePending(result.stdout, requestId)) {
180
+ await new Promise((resolve) => setTimeout(resolve, CLAUDE_FAST_PENDING_WAIT_MS));
181
+ continue;
182
+ }
183
+ return claudeCatalogFromInitialize(result.stdout, requestId, processSummary(result));
170
184
  }
171
- return claudeCatalogFromInitialize(result.stdout, requestId, processSummary(result));
172
185
  }
173
186
  finally {
174
187
  fs.rmSync(dir, { recursive: true, force: true });
175
188
  }
176
189
  }
190
+ const CLAUDE_FAST_PENDING_RETRIES = 2;
191
+ const CLAUDE_FAST_PENDING_WAIT_MS = 1_500;
192
+ /** initializeの応答が、fastの可否を「確かめている最中」と答えているか。 */
193
+ export function claudeFastStatePending(stdout, requestId) {
194
+ const response = findJsonLine(stdout, (value) => value?.type === "control_response" && value.response?.request_id === requestId);
195
+ return response?.response?.response?.fast_mode_disabled_reason === "pending";
196
+ }
197
+ // initializeの応答の fast_mode_disabled_reason と、Claude Codeが画面に出す文(2.1.296)。
198
+ const CLAUDE_FAST_DISABLED_TEXT = {
199
+ extra_usage_disabled: "Fast mode requires usage credits",
200
+ free: "Fast mode requires a paid subscription",
201
+ preference: "Fast mode has been disabled by your organization",
202
+ network_error: "Fast mode unavailable due to network connectivity issues",
203
+ not_first_party: "Fast mode is only available when using the Anthropic API directly",
204
+ disabled_by_env: "Fast mode is not available",
205
+ model_not_allowed: "Opus is not in your organization's allowed models",
206
+ unknown: "Fast mode is currently unavailable",
207
+ };
208
+ /**
209
+ * initializeの応答の理由から、fastを使えない時の断りを作る。
210
+ * 使える時(理由なし)と、まだ分からない時(pending)・聞き方の都合(sdk_opt_in_required)はnull。
211
+ */
212
+ export function claudeFastUnavailable(reason) {
213
+ if (typeof reason !== "string" || !reason || reason === "pending" || reason === "sdk_opt_in_required")
214
+ return null;
215
+ const text = CLAUDE_FAST_DISABLED_TEXT[reason];
216
+ return {
217
+ code: "FAST_MODE_UNAVAILABLE",
218
+ message: `FAST_MODE_UNAVAILABLE: Claude Codeが、このアカウントではファストを使えないと答えました(${reason}${text ? `: ${text}` : ""})。`,
219
+ };
220
+ }
177
221
  /** stream-jsonのcontrol_response(initialize)からmodel候補を作る。 */
178
222
  export function claudeCatalogFromInitialize(stdout, requestId, summary = "") {
179
223
  const response = findJsonLine(stdout, (value) => value?.type === "control_response" && value.response?.request_id === requestId);
@@ -198,14 +242,26 @@ export function claudeCatalogFromInitialize(stdout, requestId, summary = "") {
198
242
  efforts: levels.length > 0 ? [...levels, "ultracode"] : [],
199
243
  default_effort: null,
200
244
  hidden: false,
245
+ fast: model.supportsFastMode === true,
201
246
  };
202
247
  });
248
+ // fast対応はModelInfoのsupportsFastMode。一覧の値(opus等)に加えて、実体のID(resolvedModel)でも引けるようにする。
249
+ const fastIds = {};
250
+ for (const model of models) {
251
+ if (model?.supportsFastMode !== true)
252
+ continue;
253
+ fastIds[model.value] = model.value;
254
+ if (typeof model.resolvedModel === "string" && model.resolvedModel)
255
+ fastIds[model.resolvedModel] ??= model.resolvedModel;
256
+ }
203
257
  return checkedCatalog("Claude Code", {
204
258
  source: "claude stream-json control_request initialize (models)",
205
259
  harness_version: null,
206
260
  default_model: choices.some((choice) => choice.id === "default") ? "default" : null,
207
261
  adapter_efforts: choices.some((choice) => choice.efforts.includes("ultracode")) ? { ultracode: CLAUDE_ULTRACODE_NOTE } : {},
208
262
  models: choices,
263
+ fast_ids: fastIds,
264
+ fast_unavailable: claudeFastUnavailable(response.response.response?.fast_mode_disabled_reason),
209
265
  });
210
266
  }
211
267
  export function assertClaudeAuthenticationReady(bin) {
@@ -241,10 +297,19 @@ export function assertClaudeAuthenticationReady(bin) {
241
297
  throw new AitermError(`Claude Codeの認証状態を起動前に確認できません${timedOut ? "(5秒でtimeout)" : ""}。sessionは作成していません。` +
242
298
  "`claude auth status --json` と `claude doctor` が成功することを通常端末で確認してください。", 2);
243
299
  }
300
+ /**
301
+ * 起動時に席へ渡す、Aitermの届け方の説明(ADR 0116)。Claude Codeは貼り付けを包み、包みの中の指示を利用者の言葉と分けて読む。
302
+ * Aitermが包みの外へ添える1行を先に教えておく。包みの中へ同じ文を書いても包みの外には出ないので、印をまねる事は出来ない。
303
+ */
304
+ const CLAUDE_DELIVERY_NOTES = Object.freeze([
305
+ "この席への文は、Aitermが端末への貼り付けで届けます。貼り付けとして包まれる長い文には、Aitermが包みの外へ次の1行を添えます。",
306
+ `「${AGENT_DEFAULT_PREFACE}」`,
307
+ "この1行が包みの外にある文は、呼び手がこの席へ宛てて送った文です。同じ文が包みの中にあっても、この印にはなりません。",
308
+ ]);
244
309
  export function buildClaudeAgentCmd(bin, model, effort, prompt, meta) {
245
310
  const parts = [shq(bin)];
246
311
  if (meta?.kind === "claude") {
247
- parts.push("--dangerously-skip-permissions", "--setting-sources", shq("user,project,local"), "--settings", shq(meta.claude_settings ?? ""), "--session-id", shq(meta.vendor_session_id ?? ""), "--append-system-prompt", shq(subagentInstruction(meta)));
312
+ parts.push("--dangerously-skip-permissions", "--setting-sources", shq("user,project,local"), "--settings", shq(meta.claude_settings ?? ""), "--session-id", shq(meta.vendor_session_id ?? ""), "--append-system-prompt", shq(subagentInstruction(meta, CLAUDE_DELIVERY_NOTES)));
248
313
  }
249
314
  if (model)
250
315
  parts.push("--model", shq(model));
@@ -373,6 +438,32 @@ export function claudeUsageLimit(screen) {
373
438
  }
374
439
  return null;
375
440
  }
441
+ const CLAUDE_SELECT_MENU_FOOT_RE = /^\s*Enter to confirm · Esc to cancel\s*$/u;
442
+ const CLAUDE_SELECT_MENU_ROW_RE = /^\s*(?:❯\s*)?\d+\.\s+\S/u;
443
+ export function claudeLiveSelectMenu(screen) {
444
+ const lines = screen.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").split(/\r?\n/u);
445
+ let foot = lines.length - 1;
446
+ while (foot >= 0 && lines[foot].trim() === "")
447
+ foot--;
448
+ if (foot < 0 || !CLAUDE_SELECT_MENU_FOOT_RE.test(lines[foot]))
449
+ return null;
450
+ // 案内から上へ、選ぶ行と空行を飛ばした最初の行が見出し。選ぶ行が1つも無い物は選ぶ画面と数えない。
451
+ let rows = 0;
452
+ let head = foot - 1;
453
+ for (; head >= 0; head--) {
454
+ if (lines[head].trim() === "")
455
+ continue;
456
+ if (!CLAUDE_SELECT_MENU_ROW_RE.test(lines[head]))
457
+ break;
458
+ rows++;
459
+ }
460
+ if (rows === 0)
461
+ return null;
462
+ const heading = head >= 0 ? lines[head].trim() : null;
463
+ const rateLimit = heading === "What do you want to do?"
464
+ && lines.slice(head + 1, foot).some((line) => /\bStop and wait for limit to reset\b/u.test(line));
465
+ return { kind: rateLimit ? "rate_limit_options" : "unknown", heading };
466
+ }
376
467
  function lastLineIndex(lines, re) {
377
468
  for (let i = lines.length - 1; i >= 0; i--)
378
469
  if (re.test(lines[i]))
@@ -615,3 +706,30 @@ export function claudeAuthPane(screen, onboarding = false) {
615
706
  message: complete ? null : input ? "Claude Codeの公式画面で入力または初回案内の選択を完了してください。" : null,
616
707
  onboarding_complete: complete };
617
708
  }
709
+ /** `/fast on|off` を送る前の画面と後の画面から、増えた結果の行を読む。まだ出ていなければnull。 */
710
+ export function claudeFastResult(before, after) {
711
+ const count = (screen, test) => screen.split("\n").filter((line) => test.test(line)).length;
712
+ const kinds = [
713
+ ["unavailable", /Fast mode unavailable:|Fast mode is (?:not available|only available|currently unavailable)|Fast mode requires/],
714
+ ["unchanged", /Fast mode (?:was not |unchanged)|fast mode may still have been/],
715
+ ["on", /Fast mode ON/],
716
+ ["off", /Fast mode OFF/],
717
+ ];
718
+ for (const [state, test] of kinds) {
719
+ if (count(after, test) <= count(before, test))
720
+ continue;
721
+ const lines = after.split("\n");
722
+ let index = lines.length - 1;
723
+ while (index > 0 && !test.test(lines[index]))
724
+ index--;
725
+ // 使えない訳は次の行へ折り返す事がある(「… /usage-credits to」「turn them on」)。続きの行を1つだけ足す。
726
+ const next = lines[index + 1]?.trim() ?? "";
727
+ const tail = state === "unavailable" && next && !/^[❯>─╭╰│]/u.test(next) ? ` ${next}` : "";
728
+ return { state, line: `${lines[index].replace(/^\s*⎿\s*/u, "").trim()}${tail}` };
729
+ }
730
+ return null;
731
+ }
732
+ /** 入力欄の近くに、ファストが入っている印(↯)が出ているか。 */
733
+ export function claudeFastIndicatorShown(screen) {
734
+ return screen.split("\n").slice(-8).some((line) => line.includes("\u21AF"));
735
+ }
@@ -21,7 +21,7 @@ export function readCodexConfigPins(configPath) {
21
21
  body = fs.readFileSync(configPath, "utf8");
22
22
  }
23
23
  catch {
24
- return { model: { present: false, value: null }, effort: { present: false, value: null } };
24
+ return { model: { present: false, value: null }, effort: { present: false, value: null }, service_tier: { present: false, value: null } };
25
25
  }
26
26
  const rows = body.split(/\r?\n/);
27
27
  let firstTable = rows.findIndex((l) => /^\s*\[/.test(l));
@@ -37,7 +37,7 @@ export function readCodexConfigPins(configPath) {
37
37
  }
38
38
  return { present: false, value: null };
39
39
  };
40
- return { model: pick("model"), effort: pick("model_reasoning_effort") };
40
+ return { model: pick("model"), effort: pick("model_reasoning_effort"), service_tier: pick("service_tier") };
41
41
  }
42
42
  export function codexConfigSummary(configPath) {
43
43
  let body;
@@ -596,6 +596,10 @@ export function buildCodexAgentCmd(bin, model, effort, prompt, meta) {
596
596
  parts.push("--sandbox", "read-only");
597
597
  if (meta?.kind === "codex")
598
598
  parts.push("-c", "check_for_update_on_startup=false");
599
+ // 席は必ず通常の速度で始める(ADR 0114)。何も渡さないと、利用者のconfigの値や、modelの既定の段(Fastのmodelがある)で流れる。
600
+ // ここへfastは渡さない: 後で/fastで戻しても、番ごとの補助の問い合わせが起動時の値のまま残る(0.162.1で確かめた)。
601
+ if (meta?.kind === "codex")
602
+ parts.push("-c", "service_tier=default");
599
603
  // model/effort は共有configを書き換えず、CLI引数で明示して起動単位に優先する。
600
604
  if (model)
601
605
  parts.push("-m", shq(model));
@@ -828,7 +832,8 @@ export function codexLaunchBlockingDialog(screen) {
828
832
  export function codexModelChoice(screen, model) {
829
833
  for (const line of screen.slice(screen.lastIndexOf("Select Model and Effort")).split("\n")) {
830
834
  const match = line.match(/^\s*(?:›\s*)?(\d+)\.\s+(\S+)/);
831
- if (match?.[2] === model)
835
+ // 選ぶ画面は表示名(GPT-6.1-Sol)で並ぶ。一覧のID(gpt-6.1-sol)とは大文字と小文字だけが違う(0.162.1)。
836
+ if (match?.[2].toLowerCase() === model.toLowerCase())
832
837
  return match[1];
833
838
  }
834
839
  return null;
@@ -1007,6 +1012,11 @@ export async function codexModelChoices(bin, includeHidden) {
1007
1012
  }
1008
1013
  return codexCatalogFromPages(pages);
1009
1014
  }
1015
+ /** CodexのTUIの `/fast` と同じ見方: modelの速度の段(serviceTiers)に、名前が Fast の段がある。 */
1016
+ function codexModelHasFastTier(model) {
1017
+ return Array.isArray(model?.serviceTiers)
1018
+ && model.serviceTiers.some((tier) => typeof tier?.name === "string" && tier.name.toLowerCase() === "fast");
1019
+ }
1010
1020
  /** `model/list` の応答(全ページ)からmodel候補を作る。 */
1011
1021
  export function codexCatalogFromPages(pages) {
1012
1022
  let defaultModel = null;
@@ -1031,6 +1041,7 @@ export function codexCatalogFromPages(pages) {
1031
1041
  efforts,
1032
1042
  default_effort: typeof model.defaultReasoningEffort === "string" ? model.defaultReasoningEffort : null,
1033
1043
  hidden: model.hidden === true,
1044
+ fast: codexModelHasFastTier(model),
1034
1045
  };
1035
1046
  });
1036
1047
  });
@@ -1040,6 +1051,7 @@ export function codexCatalogFromPages(pages) {
1040
1051
  default_model: defaultModel,
1041
1052
  adapter_efforts: {},
1042
1053
  models,
1054
+ fast_ids: Object.fromEntries(models.filter((model) => model.fast).map((model) => [model.id, model.id])),
1043
1055
  });
1044
1056
  }
1045
1057
  export function codexAuthPlan() { return { args: ["login", "--device-auth"], env: [] }; }
@@ -1082,3 +1094,83 @@ export function codexAuthPane(screen) {
1082
1094
  return { url: authUrl(screen, ["auth.openai.com", "auth0.openai.com", "login.openai.com"]),
1083
1095
  user_code: authUserCode(screen), input_required: false, message: null };
1084
1096
  }
1097
+ // ---- ファスト(速度の段)。CodexのTUIの `/fast` は入り切りの交互で、切り替えた後に「Service tier set to …」を1行出す。
1098
+ // 足元にも /status にも今の段は出ない(0.162.1)。
1099
+ /** 画面にある「Service tier set to …」の行を、上から順に。priority は fast、default は通常の速度。 */
1100
+ export function codexServiceTierNotices(screen) {
1101
+ return screen.split("\n").flatMap((line) => {
1102
+ const match = line.match(/^\s*[•◦]?\s*Service tier (?:set to (\S+)|cleared)\s*$/);
1103
+ if (!match)
1104
+ return [];
1105
+ return [match[1] === "priority" || match[1] === "fast" ? "fast" : "standard"];
1106
+ });
1107
+ }
1108
+ /**
1109
+ * 足元のmodelの表示(「GPT-5.6-Sol xhigh fast · ~/proj」)に fast と出ているか。出ていればtrue。
1110
+ * 出ていない時はnull: 通常の速度か、足元に段を出さない構成(独自のprovider等)かを、画面からは区別できない。
1111
+ */
1112
+ export function codexFooterShowsFast(screen) {
1113
+ return screen.split("\n").slice(-6).some((line) => /^\s*\S+\s+(?:default|none|minimal|low|medium|high|xhigh|max|ultra)\s+fast\s+·\s/.test(line)) ? true : null;
1114
+ }
1115
+ /** 入力欄へ `/fast` と打った時に、命令の候補として `/fast` が出ているか(出ていない時にEnterを押すと、文として送られる)。 */
1116
+ export function codexFastCommandOffered(screen) {
1117
+ return screen.split("\n").slice(-14).some((line) => /^\s*›?\s*\/fast\s{2,}\S/.test(line));
1118
+ }
1119
+ /**
1120
+ * 利用者のconfig.tomlの最上位の項目を、公式の口(App Serverの config/batchWrite)で元の値へ戻す。
1121
+ * `/fast` は service_tier を、`/model` の選択は model と model_reasoning_effort を、利用者の既定として保存する。
1122
+ * 戻しても、切り替えた席の中の値は保たれる(0.162.1で確かめた)。
1123
+ */
1124
+ export async function restoreCodexDefaults(bin, codexHome, before) {
1125
+ // 読めない書き方の値(表や複数行)は、元の値が分からないので触らない。
1126
+ const edits = Object.entries(before).filter(([, pin]) => pin && !(pin.present && pin.value === null))
1127
+ .map(([keyPath, pin]) => ({ keyPath, value: pin.present ? pin.value : null, mergeStrategy: "replace" }));
1128
+ if (edits.length === 0)
1129
+ return;
1130
+ const parent = { thread_id: "00000000-0000-4000-8000-000000000000", codex_home: path.resolve(codexHome) };
1131
+ await steer.withCodexReceiver(AITERM_PROFILE, parent, async (request) => {
1132
+ await request("config/batchWrite", { edits });
1133
+ }, { executable: bin, timeout_ms: CODEX_MODELS_TIMEOUT_MS });
1134
+ }
1135
+ /** 公式の口で戻せなかった時のための戻し。config.tomlの最上位の、その項目の1行だけを、元の値へ直す。 */
1136
+ export function restoreCodexDefaultInFile(configPath, key, before) {
1137
+ if (before.present && before.value === null)
1138
+ return;
1139
+ let body;
1140
+ try {
1141
+ body = fs.readFileSync(configPath, "utf8");
1142
+ }
1143
+ catch {
1144
+ return;
1145
+ }
1146
+ const eol = body.includes("\r\n") ? "\r\n" : "\n";
1147
+ const rows = body.split(/\r?\n/);
1148
+ let firstTable = rows.findIndex((row) => /^\s*\[/.test(row));
1149
+ if (firstTable === -1)
1150
+ firstTable = rows.length;
1151
+ const line = rows.slice(0, firstTable).findIndex((row) => new RegExp(`^\\s*${key}\\s*=`).test(row));
1152
+ const wanted = before.present ? `${key} = ${JSON.stringify(before.value)}` : null;
1153
+ if (line === -1 && wanted === null)
1154
+ return;
1155
+ if (line !== -1 && wanted !== null && rows[line].trim() === wanted)
1156
+ return;
1157
+ if (line === -1)
1158
+ rows.splice(firstTable, 0, wanted);
1159
+ else if (wanted === null)
1160
+ rows.splice(line, 1);
1161
+ else
1162
+ rows[line] = wanted;
1163
+ const temp = `${configPath}.aiterm-restore-${process.pid}`;
1164
+ fs.writeFileSync(temp, rows.join(eol), { mode: fs.statSync(configPath).mode & 0o777 });
1165
+ try {
1166
+ fs.renameSync(temp, configPath);
1167
+ }
1168
+ catch (error) {
1169
+ // Windowsでは、ほかのprocessがfileを開いている間、置き換えに失敗する事がある。途中のfileを残さず、呼び手に次の回で試させる。
1170
+ try {
1171
+ fs.unlinkSync(temp);
1172
+ }
1173
+ catch { /* 残っても中身は前の値 */ }
1174
+ throw error;
1175
+ }
1176
+ }
@@ -8,6 +8,7 @@ import { randomBytes } from "node:crypto";
8
8
  import { AitermError } from "../errors.js";
9
9
  import { spawnAgentControlCommand } from "../agent-resolver.js";
10
10
  import { checkedCatalog } from "../model-catalog.js";
11
+ import { FastModeError } from "../fast-mode.js";
11
12
  import { AGENT_DONE_POLL_MS, CURSOR_SCREEN_POLL_MS, pollGate, AGENT_EVENT_MAX_BYTES, agentEventPath, agentHarness, agentLineageFields, agentMetadataPath, createEmpty0600, readFileRange, safeStatSize, shq, sleep, subagentInstruction, writeAgentMetadata, writeScopeLaunchNote, } from "../agent-shared.js";
12
13
  const CURSOR_TRANSCRIPT_MATCH_MAX_BYTES = 1024 * 1024;
13
14
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -350,10 +351,15 @@ export function validateCursorModelEffort(model, effort) {
350
351
  throw new AitermError("Cursor CLIのmodelへ既にparameter overrideがあります。reasoning_effortとの二重指定はできません", 2);
351
352
  }
352
353
  }
353
- export function cursorModelArgument(model, effort) {
354
+ export function cursorModelArgument(model, effort, fast = false) {
354
355
  if (!model)
355
356
  return null;
356
- return effort ? `${model}-${effort}` : model;
357
+ // fastは完成形のIDの末尾(`<model>[-<effort>]-fast`)。そのIDが一覧にあるかは assertCursorModelAvailable が見る。
358
+ return `${model}${effort ? `-${effort}` : ""}${fast ? "-fast" : ""}`;
359
+ }
360
+ /** modelへ直に書かれたIDが、fast版の完成形か(`composer-2.5-fast`、`gpt-5.3-codex-low-fast`)。 */
361
+ export function cursorModelIsFastVariant(model) {
362
+ return !!model && model.endsWith("-fast");
357
363
  }
358
364
  const CURSOR_AUTH_TIMEOUT_MS = 5_000;
359
365
  const CURSOR_MODELS_TIMEOUT_MS = 5_000;
@@ -426,11 +432,15 @@ export function cursorModelChoices(bin, cwd) {
426
432
  export function cursorCatalogFromLines(lines) {
427
433
  const choices = new Map();
428
434
  let defaultModel = null;
435
+ // fast: true で起こす時に渡すID。鍵は `${model}` と `${model}-${effort}`、値は一覧にある `-fast` 付きの完成形。
436
+ const fastIds = {};
429
437
  for (const line of lines) {
430
438
  const parsed = cursorBaseModel(line.id);
431
439
  if (!parsed)
432
440
  continue;
433
441
  const choice = choices.get(parsed.base) ?? { efforts: new Set(), label: null };
442
+ if (parsed.fast)
443
+ fastIds[parsed.effort ? `${parsed.base}-${parsed.effort}` : parsed.base] = line.id;
434
444
  // -fast版だけにあるeffortは `${model}-${effort}` の連結で作れないので数えない。
435
445
  if (parsed.effort && !parsed.fast)
436
446
  choice.efforts.add(parsed.effort);
@@ -452,13 +462,19 @@ export function cursorCatalogFromLines(lines) {
452
462
  efforts: CURSOR_SELECTABLE_EFFORTS.filter((effort) => choice.efforts.has(effort)),
453
463
  default_effort: null,
454
464
  hidden: false,
465
+ // そのmodelのどれかの組(effortなし、またはeffort付き)にfast版がある。組ごとの有無は起動時に一覧で確かめる。
466
+ fast: Object.keys(fastIds).some((key) => key === id || key.startsWith(`${id}-`)),
455
467
  })),
468
+ fast_ids: fastIds,
456
469
  });
457
470
  }
458
- export function assertCursorModelAvailable(bin, cwd, model, effort) {
459
- const effective = cursorModelArgument(model, effort);
471
+ export function assertCursorModelAvailable(bin, cwd, model, effort, fast = false) {
472
+ const effective = cursorModelArgument(model, effort, fast);
460
473
  const models = cursorModelCatalog(bin, cwd);
461
- const available = effort ? models.includes(effective) : models.includes(model) || models.some((id) => id.startsWith(`${model}-`));
474
+ if (fast && !models.includes(effective)) {
475
+ throw new FastModeError("FAST_MODE_NOT_SUPPORTED", `Cursor の一覧に ${JSON.stringify(effective)} がありません(model=${model}${effort ? `、reasoning_effort=${effort}` : ""} のfast版)。起動していません。`);
476
+ }
477
+ const available = effort || fast ? models.includes(effective) : models.includes(model) || models.some((id) => id.startsWith(`${model}-`));
462
478
  if (!available) {
463
479
  throw new AitermError(`Cursor model catalog に ${JSON.stringify(effective)} がありません。` +
464
480
  "modelとreasoning_effortから作る正規IDが存在しないため、別modelへfallbackせず中止しました", 2);
@@ -468,7 +484,7 @@ export function cursorEffortNavigation(screen, effort) {
468
484
  const label = CURSOR_EFFORT_LABELS[effort.toLowerCase()];
469
485
  if (!label)
470
486
  throw new AitermError(`Cursorのreasoning_effort ${JSON.stringify(effort)} は未対応です`, 2);
471
- const choices = screen.split("\n").flatMap((line) => {
487
+ const choices = cursorParameterLines(screen).flatMap((line) => {
472
488
  const match = line.match(/^\s*(→\s*)?[●○◯]\s+(.+?)(?:\s+✓)?\s*$/);
473
489
  return match ? [{ selected: !!match[1], label: match[2] }] : [];
474
490
  });
@@ -496,10 +512,10 @@ export function assertCursorAuthenticationReady(bin) {
496
512
  "通常端末で `agent status` が成功することを確認してください。", 2);
497
513
  }
498
514
  /** 起動argv(先頭が実行ファイル)。paneのshellに合わせた引用は呼び出し側が行う。 */
499
- export function cursorAgentArgv(bin, model, effort, prompt, meta) {
515
+ export function cursorAgentArgv(bin, model, effort, prompt, meta, fast = false) {
500
516
  validateCursorModelEffort(model, effort);
501
517
  const parts = [bin];
502
- const modelArg = cursorModelArgument(model, effort);
518
+ const modelArg = cursorModelArgument(model, effort, fast);
503
519
  if (modelArg)
504
520
  parts.push("--model", modelArg);
505
521
  // managed agentは人の対話承認を待てない。Cursor公式の無人運転フラグで
@@ -511,9 +527,9 @@ export function cursorAgentArgv(bin, model, effort, prompt, meta) {
511
527
  parts.push(meta ? cursorPromptWithLineage(meta, prompt) : prompt);
512
528
  return parts;
513
529
  }
514
- export function buildCursorAgentCmd(bin, model, effort, prompt, meta) {
530
+ export function buildCursorAgentCmd(bin, model, effort, prompt, meta, fast = false) {
515
531
  // 固定の語彙(--force、--mode ask等)は引用しない。実行ファイル、model、promptだけを引用する。
516
- const argv = cursorAgentArgv(bin, model, effort, prompt, meta);
532
+ const argv = cursorAgentArgv(bin, model, effort, prompt, meta, fast);
517
533
  return argv.map((part, index) => index === 0 || argv[index - 1] === "--model" || (prompt !== null && index === argv.length - 1) ? shq(part) : part).join(" ");
518
534
  }
519
535
  /**
@@ -551,8 +567,11 @@ const CURSOR_START_PROMPT_MARKER_RE = /(?:^|\n)\s*(?:→|->)\s*Plan,\s*search,\s
551
567
  export function cursorTuiReady(screen) {
552
568
  if (CURSOR_FOLLOWUP_MARKER_RE.test(screen))
553
569
  return true;
554
- return /Cursor Agent/i.test(screen) &&
555
- (CURSOR_COMPOSER_MARKER_RE.test(screen) || CURSOR_START_PROMPT_MARKER_RE.test(screen));
570
+ // 入力欄の案内(Plan, search, build anything)はCursorの入力欄だけが出す文なので、見出し(Cursor Agent)が画面の外へ流れた後も
571
+ // 入力待ちと読む。まだ番を流していない席で `/model` の選ぶ画面を開いて閉じると、見出しは流れて戻らない(2026.10.01)。
572
+ if (CURSOR_START_PROMPT_MARKER_RE.test(screen))
573
+ return true;
574
+ return /Cursor Agent/i.test(screen) && CURSOR_COMPOSER_MARKER_RE.test(screen);
556
575
  }
557
576
  // 利用上限に達したCursorは、入力欄も戻さずに説明を出して止まる(2026.09.26-dd393fe、macOSで実測)。
558
577
  // transcriptには、user turnを残さずにturn_ended(status "error")だけを書く事がある(実記録で8件)。その時は完了の条件
@@ -642,3 +661,68 @@ export function cursorAuthPane(screen) {
642
661
  return { url: authUrl(screen, ["cursor.com", "www.cursor.com", "auth.cursor.com"]),
643
662
  user_code: null, input_required: false, message: null };
644
663
  }
664
+ /**
665
+ * `/model` の画面の今の姿を、画面の最後の行(操作の案内)で読む。list は選ぶ画面、parameters はパラメータの画面、null は閉じている。
666
+ * Cursorは選ぶ画面を閉じても前の行を消さないので(上に残る)、文字の有無では今の姿が分からない。
667
+ */
668
+ export function cursorPickerView(screen) {
669
+ const last = screen.split("\n").map((line) => line.trim()).filter(Boolean).at(-1) ?? "";
670
+ if (/Enter to select\s*•\s*Esc to go back/.test(last))
671
+ return "parameters";
672
+ if (/Enter to select\s*•\s*Tab to edit/.test(last))
673
+ return "list";
674
+ return null;
675
+ }
676
+ /** 今開いているパラメータの画面の行(最後の「Edit Parameters」の見出しより後)。見出しが無ければ画面の全部。 */
677
+ function cursorParameterLines(screen) {
678
+ const lines = screen.split("\n");
679
+ let title = lines.length - 1;
680
+ while (title >= 0 && !lines[title].includes("Edit Parameters"))
681
+ title--;
682
+ return lines.slice(title + 1);
683
+ }
684
+ /** 今開いているパラメータの画面の選べる行。cursor は選択中(→)の行、label は印と✓を除いた名前(Low・High・Fast 等)。 */
685
+ export function cursorParameterRows(screen) {
686
+ return cursorParameterLines(screen).flatMap((line) => {
687
+ const match = line.trim().match(/^(→\s*)?[●○◯◉]\s+(.+?)(?:\s+✓)?\s*$/u);
688
+ return match ? [{ label: match[2], cursor: !!match[1] }] : [];
689
+ });
690
+ }
691
+ /** 今開いているパラメータの画面で、そのeffortの行に選択済みの印(✓)が付いているか。 */
692
+ export function cursorEffortSelected(screen, label) {
693
+ return cursorParameterLines(screen).some((line) => new RegExp(`[●○◯]\\s+${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+✓\\s*$`).test(line));
694
+ }
695
+ // ---- ファスト。Cursorは `/model` のパラメータの画面(Edit Parameters)に「Fast」の行がある(2026.10.01)。
696
+ // 入っていない: 「◯ Fast」 / 入っている: 「◉ Fast」。行が無いmodelはfastへ切り替えられない。
697
+ /** パラメータの画面のFastの行。state は今の入り切り、down は選択中(→)の行からFastの行までの下矢印の数。 */
698
+ export function cursorFastParameter(screen) {
699
+ if (!screen.includes("Edit Parameters"))
700
+ return null;
701
+ // 画面は行ごとに横へずれて描かれる事がある。行の中身だけを見る。
702
+ const rows = cursorParameterLines(screen).map((line) => line.trim()).filter((line) => /^(?:→\s*)?[●○◯◉]\s+\S/u.test(line));
703
+ const fast = rows.findIndex((line) => /^(?:→\s*)?[◯◉]\s+Fast\s*$/u.test(line));
704
+ const selected = rows.findIndex((line) => line.startsWith("→"));
705
+ if (fast < 0 || selected < 0)
706
+ return null;
707
+ return { state: rows[fast].includes("◉"), down: fast - selected };
708
+ }
709
+ /** 見出しのmodelの表示(「Codex 5.3 Low Fast」)が、ファストが入っている形か。 */
710
+ export function cursorHeaderShowsFast(screen) {
711
+ // 見出しは「<modelの表示> <権限の表示>」の1行。権限の表示は Run Everything/Ask 等(画面の幅で後ろが切れる事がある)。
712
+ const header = screen.split("\n").map((line) => line.trim())
713
+ .filter((line) => /\S\s{2,}(?:Run|Auto-Run|Ask|Plan|Manual)\b/.test(line)).at(-1);
714
+ if (!header)
715
+ return null;
716
+ return /\bFast\s{2,}/.test(header);
717
+ }
718
+ /**
719
+ * Cursorが、起動の --model と `/model` の選択のたびに、利用者の ~/.cursor/cli-config.json へ書く項目(次の会話の既定のmodelとパラメータ)。
720
+ * 書く版と書かない版がある(2026.10.01-14929f9は書く。fastを入れた選択なら `selectedModel.parameters` に fast: true が残る)。
721
+ * 席だけの切り替えにするため、切り替えの直前の値へ戻す。
722
+ */
723
+ export const CURSOR_DEFAULT_MODEL_KEYS = [
724
+ "model", "hasChangedDefaultModel", "modelParameters", "selectedModel", "modelSelectionHistory", "maxMode",
725
+ ];
726
+ export function cursorUserConfigPath(cursorHome) {
727
+ return path.join(cursorHome, "cli-config.json");
728
+ }