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.
@@ -117,16 +117,41 @@ export function grokCatalogFromInitialize(stdout, summary = "") {
117
117
  // 候補に無い既定値は黙って捨てず、checkedCatalogで形式異常にする。
118
118
  default_effort: efforts.length > 0 ? defaultEffort : null,
119
119
  hidden: false,
120
+ fast: false,
120
121
  };
121
122
  });
123
+ // Grokのfastは別のmodel(表示名が「<元の表示名> Fast」。1.0.50: Grok 4.7 と Grok 4.7 Fast=grok-4.7-build-fast)。
124
+ // fast版は別のmodelとして並べず、元のmodelの fast: true にまとめる。元のmodelが一覧に無いfast版は、そのまま1つのmodelとして残す。
125
+ const fastIds = grokFastVariants(models);
126
+ const variants = new Set(Object.values(fastIds));
127
+ const current = typeof state.currentModelId === "string" ? state.currentModelId : null;
128
+ const baseOf = (id) => Object.keys(fastIds).find((base) => fastIds[base] === id) ?? id;
122
129
  return checkedCatalog("Grok", {
123
130
  source: "grok agent stdio initialize (_meta.modelState)",
124
131
  harness_version: typeof meta.agentVersion === "string" ? meta.agentVersion : null,
125
- default_model: typeof state.currentModelId === "string" ? state.currentModelId : null,
132
+ default_model: baseOf(current),
126
133
  adapter_efforts: {},
127
- models,
134
+ models: models.filter((model) => !variants.has(model.id))
135
+ .map((model) => ({ ...model, fast: model.id in fastIds })),
136
+ fast_ids: fastIds,
128
137
  });
129
138
  }
139
+ /** 元のmodel ID → fast版のmodel ID。表示名が「<元の表示名> Fast」のmodelを、その元のmodelのfast版と読む。 */
140
+ export function grokFastVariants(models) {
141
+ const byName = new Map(models.flatMap((model) => model.display_name ? [[model.display_name, model.id]] : []));
142
+ const pairs = {};
143
+ for (const model of models) {
144
+ const match = model.display_name?.match(/^(.+) Fast$/);
145
+ const base = match ? byName.get(match[1]) : undefined;
146
+ if (base && base !== model.id)
147
+ pairs[base] = model.id;
148
+ }
149
+ return pairs;
150
+ }
151
+ /** model IDが、一覧の上でほかのmodelのfast版なら、その元のmodel ID。 */
152
+ export function grokFastBase(catalog, model) {
153
+ return Object.keys(catalog.fast_ids ?? {}).find((base) => catalog.fast_ids?.[base] === model) ?? null;
154
+ }
130
155
  export function assertGrokModelAvailable(bin, cwd, model) {
131
156
  const models = grokModelCatalog(bin, cwd);
132
157
  if (!models.includes(model)) {
@@ -630,3 +655,135 @@ export function grokAuthPane(screen) {
630
655
  }
631
656
  return { url, user_code: embedded, input_required: false, message: null };
632
657
  }
658
+ // ---- ファスト。Grokのfastは別のmodel。Grokは、起動の --model と、動いている席の `/model <ID> [effort]`・`/effort` で、
659
+ // 利用者のconfig.tomlの [models] の default と default_reasoning_effort を書き換える(1.0.50で確かめた。「✓ Default model: …」)。
660
+ // 席だけの切り替えにするため、切り替えの直前の値へ戻す。
661
+ const GROK_DEFAULT_KEYS = ["default", "default_reasoning_effort"];
662
+ /** [models] の節の中の `<key> = …` の行の番号。節や行が無ければnull。 */
663
+ function grokModelsLine(rows, key) {
664
+ const start = rows.findIndex((row) => /^\s*\[models\]\s*(?:#.*)?$/.test(row));
665
+ if (start < 0)
666
+ return null;
667
+ for (let index = start + 1; index < rows.length && !/^\s*\[/.test(rows[index]); index++) {
668
+ if (new RegExp(`^\\s*${key}\\s*=`).test(rows[index]))
669
+ return index;
670
+ }
671
+ return null;
672
+ }
673
+ export function readGrokModelDefaults(configPath) {
674
+ let body;
675
+ try {
676
+ body = fs.readFileSync(configPath, "utf8");
677
+ }
678
+ catch {
679
+ body = null;
680
+ }
681
+ return grokModelDefaultsFromBody(body);
682
+ }
683
+ /** config.tomlの中身(読めなかった時はnull)から、[models] の既定を読む。 */
684
+ export function grokModelDefaultsFromBody(body) {
685
+ if (body === null)
686
+ return { body: null, default: { present: false, value: null }, default_reasoning_effort: { present: false, value: null } };
687
+ const rows = body.split(/\r?\n/);
688
+ const pick = (key) => {
689
+ const line = grokModelsLine(rows, key);
690
+ if (line === null)
691
+ return { present: false, value: null };
692
+ const value = rows[line].match(/=\s*"([^"\\]*)"\s*(?:#.*)?$/);
693
+ return { present: true, value: value ? value[1] : null };
694
+ };
695
+ return { body, default: pick("default"), default_reasoning_effort: pick("default_reasoning_effort") };
696
+ }
697
+ function withoutGrokDefaults(body) {
698
+ const rows = body.split(/\r?\n/);
699
+ for (const key of GROK_DEFAULT_KEYS) {
700
+ const line = grokModelsLine(rows, key);
701
+ if (line !== null)
702
+ rows.splice(line, 1);
703
+ }
704
+ return rows.join("\n");
705
+ }
706
+ /**
707
+ * Grokがconfig.tomlへ書いた [models] の既定(default・default_reasoning_effort)を、切り替えの直前の値へ戻す。
708
+ * 切り替えの前後で、ほかの行が同じなら前の中身をそのまま書き戻す。ほかの行も変わっていたら、既定の行だけを直す。
709
+ * 前の中身を読めなかった時、元の値が読めない書き方だった時、今のfileに [models] の節が無い時は、その項目に触らない。
710
+ */
711
+ export function restoreGrokModelDefaults(configPath, before) {
712
+ if (before.body === null)
713
+ return;
714
+ let now;
715
+ try {
716
+ now = fs.readFileSync(configPath, "utf8");
717
+ }
718
+ catch {
719
+ return;
720
+ }
721
+ if (now === before.body)
722
+ return;
723
+ const eol = now.includes("\r\n") ? "\r\n" : "\n";
724
+ const rows = now.split(/\r?\n/);
725
+ for (const key of GROK_DEFAULT_KEYS) {
726
+ const pin = before[key];
727
+ if (pin.present && pin.value === null)
728
+ continue;
729
+ const line = grokModelsLine(rows, key);
730
+ if (pin.present) {
731
+ const wanted = `${key} = ${JSON.stringify(pin.value)}`;
732
+ if (line !== null)
733
+ rows[line] = wanted;
734
+ else {
735
+ const header = rows.findIndex((row) => /^\s*\[models\]\s*(?:#.*)?$/.test(row));
736
+ if (header >= 0)
737
+ rows.splice(header + 1, 0, wanted);
738
+ }
739
+ }
740
+ else if (line !== null) {
741
+ rows.splice(line, 1);
742
+ }
743
+ }
744
+ const edited = rows.join(eol);
745
+ if (edited === now)
746
+ return;
747
+ // ほかの行が前と同じなら、前の中身(空白や並びもそのまま)を書き戻す。
748
+ const next = withoutGrokDefaults(edited) === withoutGrokDefaults(before.body) ? before.body : edited;
749
+ const temp = `${configPath}.aiterm-restore-${process.pid}`;
750
+ fs.writeFileSync(temp, next, { mode: fs.statSync(configPath).mode & 0o777 });
751
+ try {
752
+ fs.renameSync(temp, configPath);
753
+ }
754
+ catch (error) {
755
+ // Windowsでは、ほかのprocessがfileを開いている間、置き換えに失敗する事がある。途中のfileを残さず、呼び手に次の回で試させる。
756
+ try {
757
+ fs.unlinkSync(temp);
758
+ }
759
+ catch { /* 残っても中身は前の値 */ }
760
+ throw error;
761
+ }
762
+ }
763
+ /**
764
+ * 足元の枠の行の「<modelの表示> (<effort>)」から、modelの表示(「Grok 4.7 Fast」)を読む。権限の表示(· always-approve)は、付く時と付かない時がある。
765
+ * 切り替えの知らせ(Switched to Grok 4.7 Fast (high effort))は、かっこの中に空白があるので拾わない。
766
+ */
767
+ export function grokFooterModelName(screen) {
768
+ let name = null;
769
+ for (const line of screen.split("\n")) {
770
+ const match = line.match(/(Grok [^()·│╯╰─]+?) \([a-z-]+\)/);
771
+ if (match)
772
+ name = match[1].trim();
773
+ }
774
+ return name;
775
+ }
776
+ /** 足元の「Grok 4.7 Fast (high)」から、今のmodelを一覧のIDで返す。base は元のmodel、fast はfast版かどうか。 */
777
+ export function grokModelFromFooter(screen, catalog) {
778
+ const name = grokFooterModelName(screen);
779
+ if (!name)
780
+ return null;
781
+ const fastIds = catalog.fast_ids ?? {};
782
+ for (const model of catalog.models) {
783
+ if (model.display_name === name)
784
+ return { id: model.id, base: model.id, fast: false };
785
+ if (fastIds[model.id] && `${model.display_name} Fast` === name)
786
+ return { id: fastIds[model.id], base: model.id, fast: true };
787
+ }
788
+ return null;
789
+ }
package/dist/index.js CHANGED
@@ -834,15 +834,27 @@ registerRemoteAwareTool("agent_auth", {
834
834
  return fail(error);
835
835
  }
836
836
  });
837
+ // ファスト(高速の応答。ADR 0114)。断りの符号は4つのharnessで共通で、文の先頭に付く。
838
+ const fastModeErrorCodes = ["FAST_MODE_NOT_SUPPORTED", "FAST_MODE_UNAVAILABLE", "FAST_MODE_MODEL_CONFLICT", "FAST_MODE_UNCONFIRMED"];
839
+ const fastModeFailureSchema = z.object({
840
+ code: z.enum(fastModeErrorCodes),
841
+ message: z.string(),
842
+ });
843
+ const fastModeDesc = "ファスト(harnessの高速の応答。費用や利用量が増える)。席は必ず通常の速度で起きる。trueはその席をfastへ切り替え、falseは通常の速度にする" +
844
+ "(fastに対応していないmodelでもfalseは受ける)。切り替えは席だけに留め、harnessが利用者の既定へ書いた分は切り替えの直前の値へ戻す。" +
845
+ "断りは文の先頭の符号で分かる: FAST_MODE_NOT_SUPPORTED(そのmodelは対応していない)、FAST_MODE_UNAVAILABLE(アカウントや契約で使えない)、" +
846
+ "FAST_MODE_MODEL_CONFLICT(modelへfast版のIDを書いてfalseを渡した)、FAST_MODE_UNCONFIRMED(切り替えた後の状態を確かめられない)。";
837
847
  registerRemoteAwareTool("agent_configure", {
838
- description: "起動済みのClaude/Codex/Grok/Cursor agent sessionを再起動せず、会話contextを保ったままmodel/reasoning effortを変更する。" +
848
+ description: "起動済みのClaude/Codex/Grok/Cursor agent sessionを再起動せず、会話contextを保ったままmodel/reasoning effort/fastを変更する。" +
839
849
  "各harnessのCLI標準model操作を使う。Cursorのreasoning_effort変更はmodelと同時指定する。" +
840
850
  "Claude Codeの席では /model の選ぶ画面の「この会話だけ」(s)で替え、端末の ~/.claude/settings.json の既定は書き換えない。" +
841
- "その選び方が無い版、選ぶ画面に無いmodel、そのmodelで選べないeffortは、何も変えずに断る。",
851
+ "その選び方が無い版、選ぶ画面に無いmodel、そのmodelで選べないeffortは、何も変えずに断る。" +
852
+ "fastだけの指定も受ける。" + fastModeDesc,
842
853
  inputSchema: {
843
854
  session_id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
844
855
  model: z.string().min(1).nullish().describe("変更後のmodel。省略時はmodelを変更しない"),
845
856
  reasoning_effort: z.string().min(1).nullish().describe("変更後のreasoning effort。省略時はeffortを変更しない"),
857
+ fast: z.boolean().nullish().describe("変更後のfast。省略時は、その席の今の状態を変えない"),
846
858
  },
847
859
  outputSchema: {
848
860
  schema: z.literal("aiterm.agent-configure-result.v1"),
@@ -851,10 +863,11 @@ registerRemoteAwareTool("agent_configure", {
851
863
  harness: z.enum(["claude-code", "codex-cli", "grok-cli", "cursor-cli"]),
852
864
  model: z.string().nullable(),
853
865
  reasoning_effort: z.string().nullable(),
866
+ fast: z.boolean().nullable().optional().describe("切り替えた後の、席のfastの状態。Aitermが確かめた事の無い席(前の版が起こした席)はnull"),
854
867
  },
855
- }, async ({ session_id, model, reasoning_effort }) => {
868
+ }, async ({ session_id, model, reasoning_effort, fast }) => {
856
869
  try {
857
- const result = await core.configureAgent(session_id, { model, reasoning_effort });
870
+ const result = await core.configureAgent(session_id, { model, reasoning_effort, fast });
858
871
  return {
859
872
  content: [{ type: "text", text: JSON.stringify(result) }],
860
873
  structuredContent: { ...result },
@@ -870,11 +883,14 @@ const agentModelChoiceSchema = z.object({
870
883
  efforts: z.array(z.string()).describe("このmodelで選べるreasoning_effort。空ならeffort非対応"),
871
884
  default_effort: z.string().nullable(),
872
885
  hidden: z.boolean().describe("harnessが一覧で隠すmodel(Codexのinclude_hidden時だけtrueがある)"),
886
+ fast: z.boolean().describe("このharness・このmodelでfastへ切り替えられるか(agent_launch/agent_configureのfast: true)。" +
887
+ "fastが別のmodel IDのharness(Cursor・Grok)は、fast版を別のmodelとして並べず、元のmodelのここがtrueになる"),
873
888
  });
874
889
  registerRemoteAwareTool("agent_models", {
875
890
  description: "harnessが今返すmodelとreasoning effortの候補を、そのharnessの公式の一覧から取得する。promptもturnも送らず推論を消費しない。" +
876
891
  "Codexはapp-server model/list、Claude Codeはstream-json initializeのmodels、Grokはagent stdio initializeのmodelState、" +
877
892
  "Cursorはcursor-agent modelsを素のmodel IDとeffortへ分けたもの。返るidとeffortsはagent_launch/agent_configureへそのまま渡せる。" +
893
+ "modelごとのfastは、そのmodelでfastへ切り替えられるか(harnessが返す値)。fast_unavailableは、アカウントの都合でどのmodelでも使えない時の訳。" +
878
894
  "取得不能はMODEL_CATALOG_UNAVAILABLE、形式異常はMODEL_CATALOG_INVALIDのエラーで返し、別の一覧へfallbackしない。" +
879
895
  "remoteを付けると別端末のharnessの候補を返す。",
880
896
  inputSchema: {
@@ -890,6 +906,7 @@ registerRemoteAwareTool("agent_models", {
890
906
  default_model: z.string().nullable().describe("model省略時にharnessが使うmodel。一覧のIDで表せなければnull"),
891
907
  efforts: z.array(z.string()).describe("全modelのeffortの和"),
892
908
  adapter_efforts: z.record(z.string(), z.string()).describe("harnessの一覧には無く、Aitermのadapterが足したeffortと理由"),
909
+ fast_unavailable: fastModeFailureSchema.nullable().optional().describe("このアカウント・この端末では、どのmodelでもfastを使えない時の訳(harnessが先に答えられる時だけ。今はClaude Code)。使える・分からない時はnull"),
893
910
  models: z.array(agentModelChoiceSchema),
894
911
  },
895
912
  }, async ({ harness, cwd, include_hidden }) => {
@@ -907,6 +924,11 @@ registerRemoteAwareTool("agent_models", {
907
924
  });
908
925
  // 新規APIの選択軸はharness。旧4 launcher名は移行期間中の薄いaliasとして同じ実装へ流す。
909
926
  const kindForHarness = (harness) => harness === "claude-code" ? "claude" : harness === "codex-cli" ? "codex" : harness === "cursor-cli" ? "cursor" : "grok";
927
+ const fastModeLaunchDesc = "ファスト(harnessの高速の応答。費用や利用量が増える)。省略とfalseは通常の速度で起こす(利用者の設定にfastが残っていても通常)。" +
928
+ "trueは通常で起こしてから、その席をfastへ切り替える。modelがfastに対応していない時(FAST_MODE_NOT_SUPPORTED)と、" +
929
+ "modelへfast版のIDを書いてfalseを渡した時(FAST_MODE_MODEL_CONFLICT)は起動を断る。アカウントの都合で使えない時・確かめられない時は、" +
930
+ "起動を断らずに通常の速度で起こし、返りの fast を false、fast_error に訳を入れる。" +
931
+ "modelへfast版のIDを直に書いて省略した時だけ、そのIDのまま起こす(返りの fast は true)。";
910
932
  const agentModelDesc = (kind) => kind === "claude"
911
933
  ? "起動モデル(例: claude-sonnet-4-6)。省略時はClaude CLI既定"
912
934
  : kind === "codex"
@@ -966,7 +988,7 @@ async function launchRemoteAgent(target, harness, args, extra) {
966
988
  }
967
989
  async function launchAgent(kind, args, extra) {
968
990
  const supportsWriteScope = kind !== "claude";
969
- const { prompt, image, throughline_source_session, throughline_supplement_file, model, reasoning_effort, env_vars, cwd, session_name, launch_operation_id, write_scope, trust_project } = args;
991
+ const { prompt, image, throughline_source_session, throughline_supplement_file, model, reasoning_effort, fast, env_vars, cwd, session_name, launch_operation_id, write_scope, trust_project } = args;
970
992
  let delivery = null;
971
993
  try {
972
994
  if (!supportsWriteScope && write_scope !== undefined) {
@@ -975,12 +997,13 @@ async function launchAgent(kind, args, extra) {
975
997
  const initialPrompt = image && image.length > 0 ? core.attachImages(prompt ?? "", image) : prompt ?? undefined;
976
998
  if (initialPrompt || throughline_source_session)
977
999
  delivery = await deliveryForRequest(extra);
978
- const [sid, hint, eventCursor, submitResidue, initialDelivery, startup] = await core.openAgentWithInitialPrompt(kind, {
1000
+ const [sid, hint, eventCursor, submitResidue, initialDelivery, startup, fastState] = await core.openAgentWithInitialPrompt(kind, {
979
1001
  prompt: initialPrompt,
980
1002
  throughline_source_session,
981
1003
  throughline_supplement_file,
982
1004
  model: model ?? undefined,
983
1005
  reasoning_effort: reasoning_effort ?? undefined,
1006
+ fast: fast ?? undefined,
984
1007
  env_vars,
985
1008
  trust_project,
986
1009
  before_send: delivery?.before_send,
@@ -1001,6 +1024,8 @@ async function launchAgent(kind, args, extra) {
1001
1024
  submit_residue: submitResidue,
1002
1025
  initial_prompt: initialDelivery,
1003
1026
  startup,
1027
+ fast: fastState.fast,
1028
+ fast_error: fastState.fast_error,
1004
1029
  ...(supportsWriteScope && write_scope !== undefined
1005
1030
  ? {
1006
1031
  write_scope,
@@ -1012,8 +1037,11 @@ async function launchAgent(kind, args, extra) {
1012
1037
  };
1013
1038
  // 開始未確認(submitted_unconfirmed)は誤りにしない。sessionは立ち、promptは入力欄を離れ、配送も登録済み。
1014
1039
  // 誤りで返すとClaude Code親は受け口のhookを走らせず、子の回答が届かない(ADR 0093)。状態はinitial_promptと本文で伝える。
1040
+ const fastLine = fastState.fast_error
1041
+ ? `\nfast: false(通常の速度で起こした)。${fastState.fast_error.message}`
1042
+ : fast === true ? `\nfast: ${fastState.fast}` : "";
1015
1043
  return {
1016
- content: [{ type: "text", text: `session_id: ${sid}\n${hint}${deliveryIdLine(delivery)}` }],
1044
+ content: [{ type: "text", text: `session_id: ${sid}\n${hint}${fastLine}${deliveryIdLine(delivery)}` }],
1017
1045
  structuredContent: structured,
1018
1046
  };
1019
1047
  }
@@ -1073,6 +1101,7 @@ function registerAgentTool(toolName, kind, desc) {
1073
1101
  model: z.string().nullish().describe(agentModelDesc(kind)),
1074
1102
  // CLI/model側の値集合が版で変わるため公開enumでは縛らない(core側も同方針)。
1075
1103
  reasoning_effort: z.string().nullish().describe(agentEffortDesc(kind)),
1104
+ fast: z.boolean().nullish().describe(fastModeLaunchDesc),
1076
1105
  env_vars: z.array(z.string()).optional().describe("起動したagentへ現在のMCP processから継承する環境変数名。値はtool引数へ渡さない"),
1077
1106
  trust_project: z.boolean().optional().describe("対象projectを信頼し、既知のworkspace・project hooks・MCP初期同意を起動中に進める"),
1078
1107
  cwd: z.string().nullish().describe("作業ディレクトリ(対象リポのルート等・任意)"),
@@ -1097,6 +1126,8 @@ function registerAgentTool(toolName, kind, desc) {
1097
1126
  // 初回prompt dispatch後のsubmit座礁観測(additive)。true=composerに残存を確認(submit未成立の疑い)/
1098
1127
  // false=残存を観測せず(submit成立の保証ではない)/ null=promptなし・判定不能。
1099
1128
  submit_residue: z.boolean().nullable(),
1129
+ fast: z.boolean().optional().describe("起こした後の、席のfastの状態"),
1130
+ fast_error: fastModeFailureSchema.nullable().optional().describe("fastを頼まれたが、通常の速度で起こした時の訳"),
1100
1131
  ...writeScopeOutputSchema,
1101
1132
  },
1102
1133
  }, async (args, extra) => launchAgent(kind, args, extra));
@@ -1115,6 +1146,7 @@ server.registerTool("agent_launch", {
1115
1146
  throughline_supplement_file: z.string().min(1).optional().describe("Throughline 0.10.8以降へそのまま渡すproject束縛済み長期記憶・知識の補足JSON path"),
1116
1147
  model: z.string().nullish().describe("harnessが選ぶモデル。provider名ではなくlive catalog上のmodel ID"),
1117
1148
  reasoning_effort: z.string().nullish().describe("harness adapterが標準CLI表現へ変換する思考レベル。Cursorではmodel同時指定が必要"),
1149
+ fast: z.boolean().nullish().describe(fastModeLaunchDesc),
1118
1150
  env_vars: z.array(z.string()).optional().describe("現在のMCP processから継承する環境変数名"),
1119
1151
  trust_project: z.boolean().optional().describe("対象projectを信頼し、既知のworkspace・project hooks・MCP初期同意を起動中に進める"),
1120
1152
  cwd: z.string().nullish().describe("作業ディレクトリ(絶対パス・任意)"),
@@ -1138,6 +1170,8 @@ server.registerTool("agent_launch", {
1138
1170
  wait_command: z.string().nullable(),
1139
1171
  parent_delivery: parentDeliveryOutputSchema.optional(),
1140
1172
  submit_residue: z.boolean().nullable(),
1173
+ fast: z.boolean().optional().describe("起こした後の、席のfastの状態(この項目を返さない版の別端末では無い)"),
1174
+ fast_error: fastModeFailureSchema.nullable().optional().describe("fastを頼まれたが、通常の速度で起こした時の訳"),
1141
1175
  write_scope: z.string().optional(),
1142
1176
  write_scope_enforcement: z.enum(["enforced_read_only", "declaration_only_unsupported"]).optional(),
1143
1177
  },
@@ -0,0 +1,17 @@
1
+ import { existsSync } from "node:fs";
2
+ /**
3
+ * このprocessのNodeを、後からでも起こせる場所で返す。自分でNodeを起こす時と、呼び手へ「起こす先」として渡す時に使う。
4
+ * `process.execPath`は起きた時の実体で、長く動くprocessでは後から消える事がある(Homebrewは版ごとの場所へ入れ、更新で前の版を消す。
5
+ * 2026-10-12、MacでNodeが26.10.0_1から26.11.1へ上がり、前の版から起きていたMCP process 19個が、無いNodeを起こそうとして黙って失敗していた)。
6
+ * Homebrewの時は同じformulaのopt(更新でも残る)を返す。実体が消えていて代わりも無い時は、PATHから探させる(`node`)。
7
+ * aiterm-steer-deliveryの同じ名前の関数と同じ決まり(0.4.6〜)。
8
+ */
9
+ export function liveNodeExecutable(executable = process.execPath, exists = existsSync) {
10
+ const brew = /^(.*)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/.exec(executable);
11
+ if (brew) {
12
+ const opt = `${brew[1]}/opt/${brew[2]}/bin/node`;
13
+ if (exists(opt))
14
+ return opt;
15
+ }
16
+ return exists(executable) ? executable : "node";
17
+ }
@@ -50,6 +50,7 @@ export function agentModelsResult(harness, catalog) {
50
50
  default_model: catalog.default_model,
51
51
  efforts: sortEfforts(catalog.models.flatMap(model => model.efforts)),
52
52
  adapter_efforts: catalog.adapter_efforts,
53
+ fast_unavailable: catalog.fast_unavailable ?? null,
53
54
  models: catalog.models,
54
55
  };
55
56
  }
@@ -22,6 +22,7 @@ import { createRequire } from "node:module";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { realCodexHome, readCodexWakeResult } from "aiterm-steer-delivery";
24
24
  import { AITERM_PROFILE } from "./steer-profile.js";
25
+ import { liveNodeExecutable } from "./live-node.js";
25
26
  import { checkCodexParent, submitCodexParentAnswer, withCodexReceiver } from "./codex-parent-receiver.js";
26
27
  import { codexHookDeliveryState, codexHookDirectory, codexInputDirectory } from "./codex-hook-state.js";
27
28
  export const PARENT_DELIVERY_SCHEMA = "aiterm.parent-delivery.v1";
@@ -134,7 +135,7 @@ async function deliveryState(target, delivery) {
134
135
  * アプリ配下のprocessは、PATHが細い事がある(2026-10-07、素のsshの環境からの呼び出しで起きた)。
135
136
  * nodeの場所がPATHに無い時だけ頭へ足す。既にあれば、並びも中身も変えない。
136
137
  */
137
- export function pathWithNode(env, node = process.execPath, platform = process.platform) {
138
+ export function pathWithNode(env, node = liveNodeExecutable(), platform = process.platform) {
138
139
  const windows = platform === "win32";
139
140
  // Windowsの環境変数名は大文字小文字を区別しない(`Path`で入っている事が多い)。
140
141
  const key = Object.keys(env).find(name => windows ? name.toLowerCase() === "path" : name === "PATH") ?? "PATH";
@@ -146,7 +147,7 @@ export function pathWithNode(env, node = process.execPath, platform = process.pl
146
147
  }
147
148
  /** この命令の場所と版。製品側の入口(aiterm-steer-delivery)が、呼べる相手かどうかを確かめるのに使う。 */
148
149
  export function providerDescription() {
149
- return { version: pkg.version, node: process.execPath, cli: fileURLToPath(import.meta.url) };
150
+ return { version: pkg.version, node: liveNodeExecutable(), cli: fileURLToPath(import.meta.url) };
150
151
  }
151
152
  export async function runParentDelivery(argv) {
152
153
  const [kind, command, ...args] = argv;
@@ -64,6 +64,22 @@ async function submitParentAnswer(parent, deliveryId, text) {
64
64
  const OWNER_IDENTITY_RECHECK_MS = 60_000;
65
65
  // owner.jsonの無い保存場所を、作っている途中の持ち主として扱う時間。
66
66
  const OWNER_SETUP_GRACE_MS = 60_000;
67
+ // 持ち主が自分の保存場所へ付ける「生きている印」(ADR 0112)。中身は無く、最後に書いた時刻(mtime)だけを使う。
68
+ // 印が新しい持ち主は、OSへ聞かずに生きていると読む。pidを使い回した別のprocessは、この印を書かない。
69
+ // 印の無い持ち主(0.58.3までの版)と、印が古い持ち主だけ、今までどおり開始時刻で照合する。
70
+ // 名前は.jsonで終わらせない(回収は、保存場所の.jsonを配送の記録として引き取る)。
71
+ const OWNER_HEARTBEAT_FILE = "alive";
72
+ const OWNER_HEARTBEAT_MS = 15_000;
73
+ // 持ち主のprocessが同期の処理で塞がる間(最長で十数秒)も印を新しいと読めるよう、書く間隔の4倍にする。
74
+ const OWNER_HEARTBEAT_FRESH_MS = 60_000;
75
+ function ownerHeartbeatAt(directory) {
76
+ try {
77
+ return fs.statSync(path.join(directory, OWNER_HEARTBEAT_FILE)).mtimeMs;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
67
83
  function processExists(pid) {
68
84
  // 0以下はprocess groupを指す。持ち主のpidにはならない。
69
85
  if (pid <= 0)
@@ -129,10 +145,11 @@ export class ParentDeliveryManager {
129
145
  verifiedOwners = new Map();
130
146
  serviceError = null;
131
147
  closing = false;
148
+ lastHeartbeat = Number.NEGATIVE_INFINITY;
132
149
  constructor(options = {}) {
133
150
  this.deps = { observe: observeAnywhere, answer: answerAnywhere, submit: submitParentAnswer,
134
151
  verify: verifyParent, processes: readProcessIdentities, exists: processExists,
135
- now: () => performance.now(), ...options.dependencies };
152
+ now: () => performance.now(), wall: () => Date.now(), ...options.dependencies };
136
153
  const stateRoot = ensureStateRoot();
137
154
  // 別端末の子の記録はremote-で始まる保存場所へ分け、boundary.remoteを知らない旧版のreaderへ渡さない。
138
155
  const prefix = options.remote ? "remote-" : "";
@@ -414,14 +431,40 @@ export class ParentDeliveryManager {
414
431
  this.recovering = this.recoverOrphans().finally(() => { this.recovering = null; });
415
432
  return this.recovering;
416
433
  }
434
+ /** 自分の保存場所の「生きている印」を新しくする。書けない回は次の回へ回す(印が古くなれば、相手は開始時刻で照合する)。 */
435
+ heartbeat() {
436
+ const wall = this.deps.wall();
437
+ if (this.closing || Math.abs(wall - this.lastHeartbeat) < OWNER_HEARTBEAT_MS)
438
+ return;
439
+ const file = path.join(this.ownerDir, OWNER_HEARTBEAT_FILE);
440
+ try {
441
+ const at = new Date(wall);
442
+ try {
443
+ fs.utimesSync(file, at, at);
444
+ }
445
+ catch (error) {
446
+ if (error.code !== "ENOENT")
447
+ throw error;
448
+ fs.writeFileSync(file, "", { mode: 0o600 });
449
+ fs.utimesSync(file, at, at);
450
+ }
451
+ this.lastHeartbeat = wall;
452
+ }
453
+ catch { /* 次の回 */ }
454
+ }
417
455
  async recoverOrphans() {
456
+ this.heartbeat();
418
457
  // 生存を確かめる相手は、closeしていない他の持ち主だけ。居なければOSへ問い合わせない。
419
- // この処理は5秒おきに全MCP processで回る。全processの一覧を取ると、processの多い端末で負荷になる。
458
+ // この処理は5秒おきに全MCP processで回る。processを起こして確かめると、MCP processの数の掛け算で端末を重くする
459
+ // (Windowsで、23個のMCP processがPowerShellを毎秒4回起こしていた。2026-10-12)。
420
460
  const directories = fs.readdirSync(this.active, { withFileTypes: true })
421
461
  .filter((directory) => directory.isDirectory() && path.join(this.active, directory.name) !== this.ownerDir);
422
462
  const now = this.deps.now();
463
+ const wall = this.deps.wall();
423
464
  const fate = new Map();
424
465
  const unverified = [];
466
+ // 前の照合を覚えている持ち主。誰かを照合する回に、まとめて照合し直す。
467
+ const remembered = [];
425
468
  for (const directory of directories) {
426
469
  let owner;
427
470
  try {
@@ -437,14 +480,23 @@ export class ParentDeliveryManager {
437
480
  fate.set(directory.name, "gone");
438
481
  continue;
439
482
  }
483
+ const beat = ownerHeartbeatAt(path.join(this.active, directory.name));
484
+ if (beat !== null && Math.abs(wall - beat) < OWNER_HEARTBEAT_FRESH_MS) {
485
+ fate.set(directory.name, "alive");
486
+ continue;
487
+ }
440
488
  const verified = this.verifiedOwners.get(directory.name);
441
489
  if (verified && verified.pid === pid && verified.started_identity === owner.started_identity
442
- && now - verified.at < OWNER_IDENTITY_RECHECK_MS)
490
+ && now - verified.at < OWNER_IDENTITY_RECHECK_MS) {
443
491
  fate.set(directory.name, verified.alive ? "alive" : "unverified");
492
+ remembered.push({ name: directory.name, pid, started_identity: owner.started_identity });
493
+ }
444
494
  else
445
495
  unverified.push({ name: directory.name, pid, started_identity: owner.started_identity });
446
496
  }
447
497
  if (unverified.length > 0) {
498
+ // 覚えた時刻が持ち主ごとにずれると、1分の間に何度もprocessを起こす。照合する回は、全員を1回で照合し直して時刻を揃える。
499
+ unverified.push(...remembered);
448
500
  const processes = this.deps.processes([...new Set(unverified.map((entry) => entry.pid))]);
449
501
  for (const entry of unverified) {
450
502
  const row = processes.find((candidate) => candidate.pid === entry.pid);
@@ -101,17 +101,31 @@ export function readRuntimeProcesses() {
101
101
  };
102
102
  });
103
103
  }
104
+ /** そのpidのprocessが今あるか。OSへ直接聞き、processを起こさない。権限が無くて触れないprocessも「ある」と読む。 */
105
+ function pidExists(pid) {
106
+ if (pid <= 0)
107
+ return false;
108
+ try {
109
+ process.kill(pid, 0);
110
+ return true;
111
+ }
112
+ catch (error) {
113
+ return error.code === "EPERM";
114
+ }
115
+ }
104
116
  /**
105
117
  * 指定したpidの開始時刻だけを引く。readRuntimeProcessesと同じ開始時刻を返し、存在しないpidは結果に含めない。
106
118
  * process表にpidはあるが開始時刻を読めない時は、started_identityをnullで返す(pidが無いのとは区別する)。
107
119
  * 全processのargvを読む一覧取得は、processの多い端末で重い(定期実行から呼ばない)。
108
120
  */
109
121
  export function readProcessIdentities(pids) {
110
- const wanted = [...new Set(pids)];
122
+ if (pids.some(pid => !Number.isSafeInteger(pid) || pid < 0))
123
+ throw new AitermError("process照会のpidが不正です", 2);
124
+ // 居ないpidは、OSへ直接聞いて先に除く。全部居なければ、processを起こさない。
125
+ // macOSのpsは、範囲の外のpidが1つ混ざると、居るprocessの分も出さずに誤りで終わる。
126
+ const wanted = [...new Set(pids)].filter(pidExists);
111
127
  if (wanted.length === 0)
112
128
  return [];
113
- if (wanted.some(pid => !Number.isSafeInteger(pid) || pid < 0))
114
- throw new AitermError("process照会のpidが不正です", 2);
115
129
  if (!isWin) {
116
130
  const result = spawnSync(POSIX_PS, [...POSIX_IDENTITY_ARGS, wanted.join(",")], {
117
131
  encoding: "utf8", env: { ...process.env, LC_ALL: "C" }, timeout: 10000,
@@ -129,11 +143,25 @@ export function readProcessIdentities(pids) {
129
143
  }
130
144
  // CommandLineの読めないprocess(別の権限のserviceなど)も返す。pidが使い回された先を「別のprocess」と確かめるのに要る。
131
145
  // 開始時刻を読めない行も、pidがprocess表にある印として返す。
146
+ // WMI(Win32_Process)は使わない。絞っても1回に0.1〜0.3秒かかり、WMIの側のprocess(wmiprvse)のCPUを使う
147
+ // (process 1300個のWindowsで測った 2026-10-12。ADR 0112)。.NETのprocessの読み(OpenProcessとGetProcessTimes)は1個1.5ミリ秒。
148
+ // 開始時刻は同じ値になる(WMIはマイクロ秒で切り捨てた値を返し、どちらも1ミリ秒で切り捨てて使う。888個のprocessで食い違いは0)。
149
+ // 権限が無くて読めないprocessだけ、WMIへ聞き直す。
132
150
  const script = [
133
151
  ...WINDOWS_PROBE_HEADER,
134
- `$rows=@(Get-CimInstance Win32_Process -Filter "${wanted.map(pid => `ProcessId=${pid}`).join(" OR ")}" | ForEach-Object {`,
135
- "[ordered]@{ pid=[int]$_.ProcessId; started_identity=$(if ($null -ne $_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { $null }) }",
136
- "})",
152
+ "$rows=@()",
153
+ `foreach ($id in @(${wanted.join(",")})) {`,
154
+ " try { $p=[Diagnostics.Process]::GetProcessById($id) } catch [ArgumentException] { continue }",
155
+ " $started=$null",
156
+ " try { $started=$p.StartTime.ToUniversalTime().ToString('o') }",
157
+ " catch [InvalidOperationException] { continue }",
158
+ " catch {",
159
+ " $w=Get-CimInstance Win32_Process -Filter \"ProcessId=$id\" -Property CreationDate",
160
+ " if ($null -eq $w) { continue }",
161
+ " if ($null -ne $w.CreationDate) { $started=$w.CreationDate.ToUniversalTime().ToString('o') }",
162
+ " }",
163
+ " $rows += [ordered]@{ pid=[int]$id; started_identity=$started }",
164
+ "}",
137
165
  "ConvertTo-Json -Compress -InputObject $rows",
138
166
  ].join("\n");
139
167
  const result = spawnSync(resolveWindowsPowerShell7(), [...WINDOWS_PROBE_ARGS, Buffer.from(script, "utf16le").toString("base64")], {
@@ -7,6 +7,7 @@ import { createRequire } from "node:module";
7
7
  import * as path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { defaultRuntimeErrorReportPaths, readBoundedFile, runtimeErrorReportingStatus } from "./runtime-error-os.js";
10
+ import { liveNodeExecutable } from "./live-node.js";
10
11
  import { RuntimeErrorStore } from "./runtime-error-store.js";
11
12
  const pkg = createRequire(import.meta.url)("../package.json");
12
13
  const WORKER = fileURLToPath(new URL("./runtime-error-worker.js", import.meta.url));
@@ -252,7 +253,7 @@ export function triggerRuntimeErrorReport(env = process.env) {
252
253
  try {
253
254
  if (runtimeErrorReportingStatus(defaultRuntimeErrorReportPaths().reportingConfigPath) !== "enabled")
254
255
  return false;
255
- const child = spawn(process.execPath, [WORKER, "report"], { stdio: "ignore", detached: true, windowsHide: true, env });
256
+ const child = spawn(liveNodeExecutable(), [WORKER, "report"], { stdio: "ignore", detached: true, windowsHide: true, env });
256
257
  child.once("error", () => { });
257
258
  child.unref();
258
259
  return true;
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { createRequire } from "node:module";
6
6
  import { defaultRuntimeErrorReportPaths, runtimeErrorReportingStatus } from "./runtime-error-os.js";
7
+ import { liveNodeExecutable } from "./live-node.js";
7
8
  import { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, expectedHostProfiles, readBoundedFile, processStartIdentity, forceKill } from "./runtime-error-os.js";
8
9
  export { defaultRuntimeErrorPaths, windowsPrivateDaclCommand, windowsPrivateDaclVerifyCommand } from "./runtime-error-os.js";
9
10
  import { fileURLToPath } from "node:url";
@@ -673,7 +674,7 @@ export function recordRuntimeError(code, options = {}) {
673
674
  fixedStoreFailure(stderr);
674
675
  } };
675
676
  try {
676
- const child = spawn(process.execPath, [options.workerPath ?? WORKER, "record", code], {
677
+ const child = spawn(liveNodeExecutable(), [options.workerPath ?? WORKER, "record", code], {
677
678
  stdio: "ignore", windowsHide: true, env: process.env,
678
679
  });
679
680
  const timer = setTimeout(() => { report(); forceKill(child); }, options.timeoutMs ?? defaultWorkerTimeoutMs("record"));
@@ -707,7 +708,7 @@ export async function runtimeErrorStoreDiagnostic(options = {}) {
707
708
  } };
708
709
  let child;
709
710
  try {
710
- child = spawn(process.execPath, [options.workerPath ?? WORKER, "diagnostic"], {
711
+ child = spawn(liveNodeExecutable(), [options.workerPath ?? WORKER, "diagnostic"], {
711
712
  stdio: ["ignore", "pipe", "ignore"], windowsHide: true, env: process.env,
712
713
  });
713
714
  }