aiterm-mcp 0.33.0 → 0.34.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.
package/dist/index.js CHANGED
@@ -15,19 +15,31 @@ import { z } from "zod";
15
15
  import * as core from "./core.js";
16
16
  import { runtimeErrorStoreDiagnostic } from "./runtime-error-store.js";
17
17
  import { createRequire } from "node:module";
18
+ import { ParentDeliveryManager } from "./parent-delivery.js";
19
+ import { codexParentFromRequest } from "./codex-parent-receiver.js";
18
20
  // package.json の version を実行時に読み、MCP initialize で配るサーバ版と一致させる。
19
21
  // createRequire を使うのは、import 属性 `with { type: "json" }` が Node 18.20+ 限定で
20
22
  // engines "node >=18"(18.0〜18.19)を SyntaxError で壊し、旧 `assert` 構文は逆に Node 22 で
21
23
  // 除去済み=どちらの静的構文も 18〜22 全域を満たせないため(実行時 require が唯一全域で動く)。
22
24
  const pkg = createRequire(import.meta.url)("../package.json");
23
25
  const server = new McpServer({ name: "aiterm", version: pkg.version });
26
+ let parentDelivery = null;
27
+ async function deliveryForRequest(extra) {
28
+ const parent = codexParentFromRequest(server.server.getClientVersion()?.name, extra._meta);
29
+ if (!parent)
30
+ return null;
31
+ parentDelivery ??= new ParentDeliveryManager();
32
+ await parentDelivery.prepare(parent);
33
+ return parentDelivery.request(parent);
34
+ }
24
35
  /**
25
36
  * dispatch 系の説明で共有する非ブロック規範。tool description は registerTool 時=initialize 前に
26
37
  * 固定されるため親ホストを名指しできない(ホスト別の具体形は receipt 側が core.agentWaitLaunchForm で出す)。
27
38
  * ここでは「待つな」を断定形で先に置き、foreground 実行の禁止までを説明の側に含める。
28
39
  */
29
40
  const NON_BLOCKING_RULE = "dispatch した子は投げっぱなしでよい=親はここで待たない。" +
30
- "完了通知はreceiptの `wait_process.executable` と `wait_process.args` をそのまま親のターンを塞がない別プロセスAPIへ渡して受け、" +
41
+ "Codex親にはAitermが回答本文を自動配送する。parent_deliveryがある場合はwait起動も通常の回答回収も不要。親は作業を続けるかターンを終える。" +
42
+ "その他の親では、完了通知をreceiptの `wait_process.executable` と `wait_process.args` をそのまま親のターンを塞がない別プロセスAPIへ渡して受け、" +
31
43
  "PowerShell 7のStart-Processだけは `windows_start_process_argument_list` を単一文字列として渡す。" +
32
44
  `exit を完了通知として扱う(${core.AITERM_WAIT_OUTCOME_NOTE}。ポーリング不要)。` +
33
45
  "`wait_command` は人間向け互換表示でありprocess境界へ使わない。foreground実行で親のターンを塞がない。";
@@ -38,6 +50,11 @@ const waitProcessOutputSchema = z
38
50
  windows_start_process_argument_list: z.string().nullable(),
39
51
  })
40
52
  .nullable();
53
+ const parentDeliveryOutputSchema = z.object({
54
+ delivery_id: z.string(), state: z.enum(["waiting", "ready", "sending", "submitted", "failed", "unknown"]),
55
+ child_outcome: z.enum(["done", "closed", "rate_limited", "error"]).nullable(),
56
+ child_turn_id: z.string().nullable(), queued_submission_id: z.string().nullable(), error_code: z.string().nullable(),
57
+ });
41
58
  function ok(s) {
42
59
  return { content: [{ type: "text", text: s }] };
43
60
  }
@@ -116,7 +133,7 @@ server.registerTool("pty_send", {
116
133
  "agent session(launcher起動)への send は自動で dispatch になる: TUI の ready gate と submit 分離を通して即返り、" +
117
134
  "receipt の event_cursor を返す。" +
118
135
  NON_BLOCKING_RULE +
119
- "結果回収は pty_read(agent_transcript:true)、Claude の durable turn は claude_turn を使う。" +
136
+ "自動配送以外の結果回収は pty_read(agent_transcript:true)、Claude の durable turn は claude_turn を使う。" +
120
137
  "force:true は非Claude agent sessionへの手動介入用の素送信。aiterm相関付きClaudeの承認UIはclaude_approvalを使う。",
121
138
  inputSchema: {
122
139
  session_id: z.string(),
@@ -128,7 +145,7 @@ server.registerTool("pty_send", {
128
145
  .boolean()
129
146
  .default(false)
130
147
  .describe("完了 sentinel(終了コード付き)で包む。pty_read(wait:true) が until 無しでも自動検出して" +
131
- "完了確定する(POSIX shell と PowerShell に対応。PowerShell の rc は成功0/失敗1。" +
148
+ "完了確定する(POSIX shell と PowerShell に対応。SSH先の現在の標準PS promptも自動判定。PowerShell の rc は成功0/失敗1。" +
132
149
  "fish/csh/tcsh は未対応として送信前に拒否)。" +
133
150
  " enter:false と併用すると sentinel が実行されず完了検出が発火しない(送信後に pty_key(\"Enter\") で実行される)。"),
134
151
  force: z
@@ -149,6 +166,7 @@ server.registerTool("pty_send", {
149
166
  session_id: z.string(),
150
167
  event_cursor: z.number().int().nullable(),
151
168
  wait_process: waitProcessOutputSchema,
169
+ parent_delivery: parentDeliveryOutputSchema.optional(),
152
170
  launch_id: z.string().nullable(),
153
171
  vendor: z.enum(["claude", "codex", "grok", "composer", "cursor"]).nullable(),
154
172
  harness: z.enum(["claude-code", "codex-cli", "grok-cli", "cursor-cli"]).nullable(),
@@ -158,7 +176,8 @@ server.registerTool("pty_send", {
158
176
  // dispatch前に行った pane 入力の回復("fg" / "fg_stopped" / "stty_raw")。通常送信では省略(additive)。
159
177
  pane_input_recovery: z.array(z.string()).optional(),
160
178
  },
161
- }, async ({ session_id, text, enter, mark, force, rtk, raw, image }) => {
179
+ }, async ({ session_id, text, enter, mark, force, rtk, raw, image }, extra) => {
180
+ let delivery = null;
162
181
  try {
163
182
  if (!force && core.isAgentSession(session_id)) {
164
183
  if (enter === false)
@@ -167,8 +186,9 @@ server.registerTool("pty_send", {
167
186
  throw new Error("agent session への dispatch は mark:true と併用できません");
168
187
  if (rtk)
169
188
  throw new Error("agent session への dispatch は rtk:true と併用できません");
170
- const receipt = await core.dispatchAgentTurn(session_id, core.attachImages(text, image), { raw });
171
- const waitProcess = core.agentWaitProcess(receipt.session_id, receipt.event_cursor);
189
+ delivery = await deliveryForRequest(extra);
190
+ const receipt = await core.dispatchAgentTurn(session_id, core.attachImages(text, image), { raw, before_send: delivery?.before_send });
191
+ const waitProcess = delivery ? null : core.agentWaitProcess(receipt.session_id, receipt.event_cursor);
172
192
  return {
173
193
  content: [
174
194
  {
@@ -184,6 +204,7 @@ server.registerTool("pty_send", {
184
204
  session_id: receipt.session_id,
185
205
  event_cursor: receipt.event_cursor,
186
206
  wait_process: waitProcess,
207
+ ...(delivery?.result() ? { parent_delivery: delivery.result() } : {}),
187
208
  launch_id: receipt.launch_id,
188
209
  vendor: receipt.vendor,
189
210
  harness: receipt.harness,
@@ -212,6 +233,7 @@ server.registerTool("pty_send", {
212
233
  };
213
234
  }
214
235
  catch (e) {
236
+ delivery?.failed(e);
215
237
  return fail(e);
216
238
  }
217
239
  });
@@ -385,6 +407,7 @@ server.registerTool("pty_close", {
385
407
  },
386
408
  }, async ({ session_id }) => {
387
409
  try {
410
+ await parentDelivery?.beforeChange(session_id);
388
411
  const result = core.closeSessionResult(session_id);
389
412
  return {
390
413
  content: [{ type: "text", text: `closed ${session_id}` }],
@@ -437,13 +460,15 @@ server.registerTool("pty_observe", {
437
460
  pane_process: nativeProcessIdentitySchema.nullable(), harness_process: nativeProcessIdentitySchema.nullable(),
438
461
  process_identity: nativeProcessIdentitySchema.nullable(),
439
462
  token_hint: z.number().nullable(),
463
+ parent_deliveries: z.array(parentDeliveryOutputSchema).optional(),
440
464
  activity: z.object({ cursor: z.string().nullable(), output_changed: z.boolean().nullable(),
441
465
  cpu_seconds: z.number().nullable(), cpu_delta_seconds: z.number().nullable(), cpu_delta_complete: z.boolean().nullable(),
442
466
  background_cpu_seconds: z.number().nullable(), background_cpu_delta_seconds: z.number().nullable(), background_cpu_delta_complete: z.boolean().nullable() }),
443
467
  },
444
468
  }, async ({ session_id, cursor }) => {
445
469
  try {
446
- const result = core.observeSession(session_id, cursor);
470
+ const observation = core.observeSession(session_id, cursor);
471
+ const result = { ...observation, ...(parentDelivery ? { parent_deliveries: parentDelivery.status(session_id) } : {}) };
447
472
  return { ...ok(JSON.stringify(result)), structuredContent: { ...result } };
448
473
  }
449
474
  catch (e) {
@@ -493,21 +518,27 @@ server.registerTool("claude_turn", {
493
518
  // issue時のみdispatch由来のsubmit座礁観測(additive)。true=composerに残存を確認(submit未成立の疑い)/
494
519
  // false=残存を観測せず(成立の保証ではない)/ recover等はnull。
495
520
  submit_residue: z.boolean().nullable(),
521
+ parent_delivery: parentDeliveryOutputSchema.optional(),
496
522
  },
497
- }, async ({ action, session_id, operation_id, text }) => {
523
+ }, async ({ action, session_id, operation_id, text }, extra) => {
524
+ let delivery = null;
498
525
  try {
526
+ if (action === "issue")
527
+ delivery = await deliveryForRequest(extra);
499
528
  const result = await core.runClaudeOperation({
500
529
  action,
501
530
  session_id,
502
531
  operation_id,
503
532
  text: text ?? undefined,
533
+ before_send: delivery?.before_send,
504
534
  });
505
535
  return {
506
536
  content: [{ type: "text", text: JSON.stringify(result) }],
507
- structuredContent: { ...result },
537
+ structuredContent: { ...result, ...(delivery?.result() ? { parent_delivery: delivery.result() } : {}) },
508
538
  };
509
539
  }
510
540
  catch (e) {
541
+ delivery?.failed(e);
511
542
  return fail(e);
512
543
  }
513
544
  });
@@ -613,12 +644,7 @@ const agentEffortDesc = (kind) => kind === "claude"
613
644
  : "Grok Build reasoning effort。利用可能値はCLI/modelのlive catalogに従う。省略時はCLI/model既定。";
614
645
  // 全launcher共通の完了受信ガイド。machine callerはreceiptのwait_processをそのまま別process APIへ渡す。
615
646
  // wait_commandは人間向け互換表示。文型は NON_BLOCKING_RULE と同じく「待たない」が先。
616
- const agentCompletionDesc = `起動して投げたら投げっぱなしでよい=親はここで待たない。` +
617
- `完了通知は起動応答またはpty_send dispatch receiptの wait_processを、親のターンを塞がない` +
618
- `別プロセスAPIへexecutable/argsの境界を保ったまま渡して受ける` +
619
- `(PowerShell 7のStart-Processはwindows_start_process_argument_listを使う)` +
620
- `(${core.AITERM_WAIT_OUTCOME_NOTE}。ポーリング不要・foreground実行はしない)。` +
621
- `wait_commandは人間向け互換表示。結果回収は pty_read(agent_transcript:true)。`;
647
+ const agentCompletionDesc = NON_BLOCKING_RULE + "自動配送以外の結果回収は pty_read(agent_transcript:true)。";
622
648
  const agentEnvironmentDesc = `通常CLIと同じHOME・cwd・project/user/local設定・MCP・plugin・skill・permission/trustを共有する。` +
623
649
  `aitermは完了相関stateだけをlaunch単位で所有する。起動されたagentにはsub-agent自己認識、親session、` +
624
650
  `delegation depth/lineage、delegation_allowed=trueを注入し、必要な追加委譲は許可する。`;
@@ -627,14 +653,17 @@ const initialPromptDeliverySchema = z.object({
627
653
  reason: z.string(), turn_started: z.boolean().nullable(),
628
654
  });
629
655
  const agentStartupSchema = z.object({ status: z.enum(["ready", "not_checked", "blocked"]), reason: z.string() });
630
- async function launchAgent(kind, args) {
656
+ async function launchAgent(kind, args, extra) {
631
657
  const supportsWriteScope = kind !== "claude";
632
658
  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;
659
+ let delivery = null;
633
660
  try {
634
661
  if (!supportsWriteScope && write_scope !== undefined) {
635
662
  throw new core.AitermError("claude-code harnessはwrite_scopeに対応していません。指定を外してください", 2);
636
663
  }
637
664
  const initialPrompt = image && image.length > 0 ? core.attachImages(prompt ?? "", image) : prompt ?? undefined;
665
+ if (initialPrompt || throughline_source_session)
666
+ delivery = await deliveryForRequest(extra);
638
667
  const [sid, hint, eventCursor, submitResidue, initialDelivery, startup] = await core.openAgentWithInitialPrompt(kind, {
639
668
  prompt: initialPrompt,
640
669
  throughline_source_session,
@@ -643,6 +672,7 @@ async function launchAgent(kind, args) {
643
672
  reasoning_effort: reasoning_effort ?? undefined,
644
673
  env_vars,
645
674
  trust_project,
675
+ before_send: delivery?.before_send,
646
676
  cwd: cwd ?? undefined,
647
677
  session_name: session_name ?? undefined,
648
678
  launch_operation_id: launch_operation_id ?? undefined,
@@ -655,8 +685,9 @@ async function launchAgent(kind, args) {
655
685
  session_id: sid,
656
686
  managed_completion: true,
657
687
  event_cursor: eventCursor,
658
- wait_process: eventCursor === null ? null : core.agentWaitProcess(sid, eventCursor),
659
- wait_command: eventCursor === null ? null : `aiterm-wait --session ${sid} --cursor ${eventCursor}`,
688
+ wait_process: eventCursor === null || delivery ? null : core.agentWaitProcess(sid, eventCursor),
689
+ wait_command: eventCursor === null || delivery ? null : `aiterm-wait --session ${sid} --cursor ${eventCursor}`,
690
+ ...(delivery?.result() ? { parent_delivery: delivery.result() } : {}),
660
691
  submit_residue: submitResidue,
661
692
  initial_prompt: initialDelivery,
662
693
  startup,
@@ -676,14 +707,16 @@ async function launchAgent(kind, args) {
676
707
  };
677
708
  }
678
709
  catch (e) {
710
+ delivery?.failed(e);
679
711
  if (e instanceof core.AgentLaunchPromptError) {
680
712
  return {
681
713
  ...fail(e),
682
714
  structuredContent: {
683
715
  schema: "aiterm.agent-launch-result.v1", provider: kind, harness: core.agentHarness(kind),
684
716
  session_id: e.session_id, managed_completion: true, event_cursor: e.event_cursor,
685
- wait_process: e.event_cursor === null ? null : core.agentWaitProcess(e.session_id, e.event_cursor),
686
- wait_command: e.event_cursor === null ? null : `aiterm-wait --session ${e.session_id} --cursor ${e.event_cursor}`,
717
+ wait_process: e.event_cursor === null || delivery ? null : core.agentWaitProcess(e.session_id, e.event_cursor),
718
+ wait_command: e.event_cursor === null || delivery ? null : `aiterm-wait --session ${e.session_id} --cursor ${e.event_cursor}`,
719
+ ...(delivery?.result() ? { parent_delivery: delivery.result() } : {}),
687
720
  submit_residue: null, initial_prompt: e.initial_prompt, startup: e.startup,
688
721
  },
689
722
  };
@@ -750,12 +783,13 @@ function registerAgentTool(toolName, kind, desc) {
750
783
  event_cursor: z.number().int().nullable(),
751
784
  wait_process: waitProcessOutputSchema,
752
785
  wait_command: z.string().nullable(),
786
+ parent_delivery: parentDeliveryOutputSchema.optional(),
753
787
  // 初回prompt dispatch後のsubmit座礁観測(additive)。true=composerに残存を確認(submit未成立の疑い)/
754
788
  // false=残存を観測せず(submit成立の保証ではない)/ null=promptなし・判定不能。
755
789
  submit_residue: z.boolean().nullable(),
756
790
  ...writeScopeOutputSchema,
757
791
  },
758
- }, async (args) => launchAgent(kind, args));
792
+ }, async (args, extra) => launchAgent(kind, args, extra));
759
793
  }
760
794
  server.registerTool("agent_launch", {
761
795
  description: "エージェントを単一の標準入口から永続sessionへ起動する。harnessはagent loop・認証・hook・transcriptを所有する実行基盤、" +
@@ -788,11 +822,12 @@ server.registerTool("agent_launch", {
788
822
  event_cursor: z.number().int().nullable(),
789
823
  wait_process: waitProcessOutputSchema,
790
824
  wait_command: z.string().nullable(),
825
+ parent_delivery: parentDeliveryOutputSchema.optional(),
791
826
  submit_residue: z.boolean().nullable(),
792
827
  write_scope: z.string().optional(),
793
828
  write_scope_enforcement: z.enum(["enforced_read_only", "declaration_only_unsupported"]).optional(),
794
829
  },
795
- }, async ({ harness, ...args }) => launchAgent(kindForHarness(harness), args));
830
+ }, async ({ harness, ...args }, extra) => launchAgent(kindForHarness(harness), args, extra));
796
831
  registerAgentTool("claude_agent", "claude", "【旧互換alias。新規連携は agent_launch(harness=claude-code)】Claude Codeの対話エージェントTUIを永続端末に起動する。`claude -p`ではなく、" +
797
832
  "同じ利用者可視sessionへpty_sendで継続入力する。" +
798
833
  agentEnvironmentDesc +
@@ -824,7 +859,13 @@ async function main() {
824
859
  // そのホストの実際の起動形で名指しする(実測: claude-code は initialize → notifications/initialized
825
860
  // → tools/list の順で送るため、どの tool 呼び出しより先に確定する)。取れない時は汎用文へ落ちるだけ。
826
861
  server.server.oninitialized = () => {
827
- core.setParentClient(server.server.getClientVersion()?.name ?? null);
862
+ const name = server.server.getClientVersion()?.name;
863
+ core.setParentClient(name ?? null);
864
+ if (name === "codex-mcp-client")
865
+ parentDelivery ??= new ParentDeliveryManager();
866
+ };
867
+ server.server.onclose = () => {
868
+ void parentDelivery?.close().catch(() => process.stderr.write("aiterm: PARENT_DELIVERY_CLOSE_FAILED\n"));
828
869
  };
829
870
  const transport = new StdioServerTransport();
830
871
  await server.connect(transport);
@@ -0,0 +1,382 @@
1
+ // Aitermが所有する、子の完了観測・回答本文の保存・Codex親への配送。
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { randomUUID, createHash } from "node:crypto";
5
+ import { z } from "zod";
6
+ import { observeAgentDone, readAgentTranscriptResult } from "./core.js";
7
+ import { ensureStateRoot, writeJson0600 } from "./agent-shared.js";
8
+ import { readRuntimeProcesses } from "./process-runtime.js";
9
+ import { CodexDeliveryError, submitCodexParentAnswer, verifyCodexParent } from "./codex-parent-receiver.js";
10
+ import { AitermError } from "./errors.js";
11
+ const recordSchema = z.object({
12
+ schema: z.literal("aiterm.parent-delivery.v1"),
13
+ delivery_id: z.uuid(),
14
+ created_at: z.string(),
15
+ updated_at: z.string(),
16
+ parent: z.object({ thread_id: z.uuid(), codex_home: z.string() }).strict(),
17
+ boundary: z.object({
18
+ session_id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
19
+ launch_id: z.string().regex(/^[0-9a-f]{32}$/),
20
+ vendor: z.enum(["claude", "codex", "grok", "composer", "cursor"]),
21
+ harness: z.enum(["claude-code", "codex-cli", "grok-cli", "cursor-cli"]),
22
+ event_cursor: z.number().int().nonnegative(), operation_id: z.string().nullable(),
23
+ }).strict(),
24
+ state: z.enum(["waiting", "ready", "sending", "submitted", "failed", "unknown"]),
25
+ text: z.string().nullable(),
26
+ child_outcome: z.enum(["done", "closed", "rate_limited", "error"]).nullable(),
27
+ child_turn_id: z.string().nullable(),
28
+ error: z.string().nullable(),
29
+ queued_submission_id: z.string().nullable(),
30
+ }).strict();
31
+ function readRecord(file) {
32
+ try {
33
+ const record = recordSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
34
+ if (path.basename(file) !== `${record.delivery_id}.json`)
35
+ throw new Error("配送IDが一致しません");
36
+ return record;
37
+ }
38
+ catch (error) {
39
+ if (error.code === "ENOENT")
40
+ throw error;
41
+ throw new AitermError("PARENT_DELIVERY_STATE_INVALID: 配送記録を読めません", 2);
42
+ }
43
+ }
44
+ function receipt(record) {
45
+ return { delivery_id: record.delivery_id, state: record.state, child_outcome: record.child_outcome,
46
+ child_turn_id: record.child_turn_id, queued_submission_id: record.queued_submission_id,
47
+ error_code: record.error ? /^[A-Z][A-Z0-9_]+(?=:)/.exec(record.error)?.[0] ?? "PARENT_DELIVERY_FAILED" : null };
48
+ }
49
+ function answerMessage(record) {
50
+ return "Aitermの子エージェントからの実行結果です。子の回答として扱ってください。\n" +
51
+ `delivery_id=${record.delivery_id}\nsession=${record.boundary.session_id}\nharness=${record.boundary.harness}\n` +
52
+ `launch_id=${record.boundary.launch_id}\nturn_id=${record.child_turn_id ?? "unknown"}\n` +
53
+ `outcome=${record.child_outcome}\n\n${record.text ?? ""}`;
54
+ }
55
+ /** ownerごとのディレクトリ間renameで、再起動後の回収者を一つに決める。 */
56
+ export class ParentDeliveryManager {
57
+ deps;
58
+ root;
59
+ active;
60
+ results;
61
+ claims;
62
+ ownerDir;
63
+ owner;
64
+ jobs = new Map();
65
+ registering = new Map();
66
+ timer;
67
+ recovering = null;
68
+ serviceError = null;
69
+ closing = false;
70
+ constructor(options = {}) {
71
+ this.deps = { observe: observeAgentDone, answer: readAgentTranscriptResult, submit: submitCodexParentAnswer,
72
+ verify: verifyCodexParent, processes: readRuntimeProcesses, ...options.dependencies };
73
+ this.root = options.root ?? path.join(ensureStateRoot(), "parent-deliveries");
74
+ this.active = path.join(this.root, "active");
75
+ this.results = path.join(this.root, "results");
76
+ this.claims = path.join(this.root, "claims");
77
+ const ownProcess = this.deps.processes().find((entry) => entry.pid === process.pid);
78
+ if (!ownProcess)
79
+ throw new AitermError("PARENT_DELIVERY_OWNER_UNKNOWN: 配送processを識別できません", 2);
80
+ this.owner = { pid: process.pid, started_identity: ownProcess.started_identity, closed: false };
81
+ const identity = createHash("sha256").update(ownProcess.started_identity).digest("hex").slice(0, 16);
82
+ this.ownerDir = path.join(this.active, `${process.pid}-${identity}-${randomUUID()}`);
83
+ fs.mkdirSync(this.ownerDir, { recursive: true, mode: 0o700 });
84
+ fs.mkdirSync(this.results, { recursive: true, mode: 0o700 });
85
+ fs.mkdirSync(this.claims, { recursive: true, mode: 0o700 });
86
+ writeJson0600(path.join(this.ownerDir, "owner.json"), this.owner);
87
+ // MCPの再接続と旧process終了の順序が前後しても、終了したownerの記録を回収する。
88
+ this.timer = setInterval(() => { void this.recover().catch((error) => this.reportServiceError(error)); }, 5_000);
89
+ this.timer.unref();
90
+ void this.recover().catch((error) => this.reportServiceError(error));
91
+ }
92
+ reportServiceError(error) {
93
+ this.serviceError = error instanceof Error ? error : new Error(String(error));
94
+ process.stderr.write("aiterm: PARENT_DELIVERY_STATE_UNAVAILABLE(配送状態を確認してください)\n");
95
+ }
96
+ async prepare(parent) {
97
+ if (this.closing)
98
+ throw new AitermError("PARENT_DELIVERY_CLOSED: MCP接続を終了しています", 2);
99
+ if (this.serviceError)
100
+ throw this.serviceError;
101
+ await this.recover();
102
+ await this.deps.verify(parent);
103
+ }
104
+ request(parent) {
105
+ let job = null;
106
+ return {
107
+ before_send: async (boundary) => {
108
+ const previous = this.registering.get(boundary.session_id) ?? Promise.resolve();
109
+ const registration = previous.then(async () => {
110
+ await this.beforeChange(boundary.session_id, true);
111
+ const now = new Date().toISOString();
112
+ const record = { schema: "aiterm.parent-delivery.v1", delivery_id: randomUUID(),
113
+ created_at: now, updated_at: now, parent, boundary, state: "waiting", text: null,
114
+ child_outcome: null, child_turn_id: null, error: null, queued_submission_id: null };
115
+ job = { record, file: path.join(this.ownerDir, `${record.delivery_id}.json`), controller: new AbortController() };
116
+ this.save(job);
117
+ // 記録を調べてから作るだけでは、別processが同時に同じ子を予約できる。
118
+ // 同一filesystemのhard link作成で、回答をまだ保存していない依頼を一つに決める。
119
+ try {
120
+ fs.linkSync(job.file, path.join(this.claims, `${boundary.session_id}.json`));
121
+ }
122
+ catch (error) {
123
+ fs.unlinkSync(job.file);
124
+ job = null;
125
+ if (error.code === "EEXIST") {
126
+ throw new AitermError("PARENT_RESULT_PENDING: 同じ子の回答保存を別の依頼が所有しています", 2);
127
+ }
128
+ throw error;
129
+ }
130
+ this.jobs.set(record.delivery_id, job);
131
+ this.watch(job);
132
+ });
133
+ this.registering.set(boundary.session_id, registration);
134
+ try {
135
+ await registration;
136
+ }
137
+ finally {
138
+ if (this.registering.get(boundary.session_id) === registration)
139
+ this.registering.delete(boundary.session_id);
140
+ }
141
+ },
142
+ result: () => job ? receipt(job.record) : null,
143
+ failed: (error) => {
144
+ if (!job || job.record.state !== "waiting")
145
+ return;
146
+ job.controller.abort();
147
+ job.record.state = "unknown";
148
+ job.record.error = `AGENT_DISPATCH_UNCONFIRMED: ${error instanceof Error ? error.message : String(error)}`;
149
+ this.finish(job);
150
+ },
151
+ };
152
+ }
153
+ save(job) {
154
+ job.record.updated_at = new Date().toISOString();
155
+ writeJson0600(job.file, job.record);
156
+ }
157
+ finish(job) {
158
+ this.releaseClaim(job);
159
+ this.save(job);
160
+ const destination = path.join(this.results, `${job.record.delivery_id}.json`);
161
+ if (job.file !== destination) {
162
+ fs.renameSync(job.file, destination);
163
+ job.file = destination;
164
+ }
165
+ this.jobs.delete(job.record.delivery_id);
166
+ }
167
+ releaseClaim(job) {
168
+ const file = path.join(this.claims, `${job.record.boundary.session_id}.json`);
169
+ try {
170
+ const claim = JSON.parse(fs.readFileSync(file, "utf8"));
171
+ // 回収済みrecordを復旧している間に始まった、次の依頼の予約は触らない。
172
+ if (claim.delivery_id === job.record.delivery_id)
173
+ fs.unlinkSync(file);
174
+ }
175
+ catch (error) {
176
+ if (error.code !== "ENOENT")
177
+ throw error;
178
+ }
179
+ }
180
+ watch(job) {
181
+ const boundary = job.record.boundary;
182
+ job.task = (async () => {
183
+ try {
184
+ const completion = await this.deps.observe(boundary.session_id, {
185
+ cursor: boundary.event_cursor, operation_id: boundary.operation_id,
186
+ timeout: Infinity, signal: job.controller.signal,
187
+ });
188
+ await this.capture(job, completion);
189
+ }
190
+ catch (error) {
191
+ if (job.controller.signal.aborted)
192
+ return;
193
+ await this.captureFailure(job, error);
194
+ }
195
+ })().catch((error) => this.reportServiceError(error));
196
+ }
197
+ async capture(job, completion) {
198
+ if (job.capture)
199
+ return job.capture;
200
+ if (job.record.state !== "waiting")
201
+ return;
202
+ job.capture = (async () => {
203
+ if (completion.launch_id !== job.record.boundary.launch_id)
204
+ throw new AitermError("PARENT_DELIVERY_LAUNCH_CHANGED: 子のlaunchが置換されました", 2);
205
+ if (completion.outcome === "running" || completion.outcome === "timeout")
206
+ throw new AitermError("PARENT_DELIVERY_NOT_COMPLETED: 子の完了を確認できません", 2);
207
+ const record = job.record;
208
+ record.child_outcome = completion.outcome;
209
+ record.child_turn_id = completion.turn_id;
210
+ if (completion.outcome === "done") {
211
+ const answer = await this.deps.answer(record.boundary.session_id, { completion, operation_id: completion.operation_id, raw: true });
212
+ if (record.state !== "waiting")
213
+ return;
214
+ record.text = answer.text;
215
+ }
216
+ else {
217
+ record.text = completion.error ?? completion.rate_limit ?? "子のセッションが閉じられたため、回答はありません。";
218
+ }
219
+ record.state = "ready";
220
+ this.save(job);
221
+ // 次の依頼へ進める時点は本文保存まで。親のキュー受付を待たせない。
222
+ job.delivery = this.deliver(job).catch((error) => this.reportServiceError(error));
223
+ })();
224
+ return job.capture;
225
+ }
226
+ async captureFailure(job, error) {
227
+ if (job.record.state !== "waiting")
228
+ throw error;
229
+ job.record.child_outcome = "error";
230
+ job.record.error = `AGENT_RESULT_UNAVAILABLE: ${error instanceof Error ? error.message : String(error)}`;
231
+ job.record.text = job.record.error;
232
+ job.record.state = "ready";
233
+ this.save(job);
234
+ await this.deliver(job);
235
+ }
236
+ async deliver(job) {
237
+ this.releaseClaim(job);
238
+ job.record.state = "sending";
239
+ this.save(job);
240
+ try {
241
+ const result = await this.deps.submit(job.record.parent, job.record.delivery_id, answerMessage(job.record));
242
+ job.record.queued_submission_id = result.queued_submission_id;
243
+ job.record.state = "submitted";
244
+ }
245
+ catch (error) {
246
+ job.record.state = error instanceof CodexDeliveryError && !error.outcome_unknown ? "failed" : "unknown";
247
+ job.record.error = error instanceof Error ? error.message : String(error);
248
+ }
249
+ this.finish(job);
250
+ }
251
+ /** 次の入力・closeより先に、既に完了した前の本文を確保する。 */
252
+ async beforeChange(sessionId, requireCaptured = false) {
253
+ for (const job of this.jobs.values()) {
254
+ if (job.record.boundary.session_id !== sessionId || job.record.state !== "waiting")
255
+ continue;
256
+ const boundary = job.record.boundary;
257
+ const completion = await this.deps.observe(sessionId, { cursor: boundary.event_cursor, operation_id: boundary.operation_id, timeout: 0 });
258
+ if (completion.outcome !== "running" && completion.outcome !== "timeout") {
259
+ await this.capture(job, completion);
260
+ }
261
+ if (requireCaptured && job.record.state === "waiting") {
262
+ throw new AitermError("PARENT_RESULT_PENDING: 前の依頼の完了と回答保存が済んでいません", 2);
263
+ }
264
+ }
265
+ // 別のMCP processが同じ子の回答を保存中なら、入力で記録を上書きしない。
266
+ if (requireCaptured && this.records().some((record) => record.boundary.session_id === sessionId && record.state === "waiting")) {
267
+ throw new AitermError("PARENT_RESULT_PENDING: 別の依頼の回答保存が済んでいません", 2);
268
+ }
269
+ }
270
+ files() {
271
+ const files = fs.readdirSync(this.results).filter((name) => name.endsWith(".json")).map((name) => path.join(this.results, name));
272
+ for (const owner of fs.readdirSync(this.active, { withFileTypes: true })) {
273
+ if (!owner.isDirectory())
274
+ continue;
275
+ const directory = path.join(this.active, owner.name);
276
+ files.push(...fs.readdirSync(directory).filter((name) => name !== "owner.json" && name.endsWith(".json")).map((name) => path.join(directory, name)));
277
+ }
278
+ return files;
279
+ }
280
+ records() {
281
+ const records = [];
282
+ for (const file of this.files()) {
283
+ try {
284
+ records.push(readRecord(file));
285
+ }
286
+ catch (error) {
287
+ if (error.code !== "ENOENT")
288
+ throw error;
289
+ }
290
+ }
291
+ return records;
292
+ }
293
+ status(sessionId) {
294
+ if (this.serviceError)
295
+ throw this.serviceError;
296
+ return this.records().filter((record) => record.boundary.session_id === sessionId)
297
+ .sort((a, b) => a.created_at.localeCompare(b.created_at)).map(receipt);
298
+ }
299
+ recover() {
300
+ if (this.recovering)
301
+ return this.recovering;
302
+ if (this.closing)
303
+ return Promise.resolve();
304
+ this.recovering = this.recoverOrphans().finally(() => { this.recovering = null; });
305
+ return this.recovering;
306
+ }
307
+ async recoverOrphans() {
308
+ const processes = this.deps.processes();
309
+ for (const directory of fs.readdirSync(this.active, { withFileTypes: true })) {
310
+ if (!directory.isDirectory())
311
+ continue;
312
+ const oldDir = path.join(this.active, directory.name);
313
+ if (oldDir === this.ownerDir)
314
+ continue;
315
+ let owner;
316
+ try {
317
+ owner = JSON.parse(fs.readFileSync(path.join(oldDir, "owner.json"), "utf8"));
318
+ }
319
+ catch (error) {
320
+ if (error.code === "ENOENT")
321
+ continue;
322
+ throw new AitermError("PARENT_DELIVERY_OWNER_INVALID: 配送所有者の記録を読めません", 2);
323
+ }
324
+ if (!Number.isSafeInteger(owner.pid) || typeof owner.started_identity !== "string" || typeof owner.closed !== "boolean") {
325
+ throw new AitermError("PARENT_DELIVERY_OWNER_INVALID: 配送所有者の形式が不正です", 2);
326
+ }
327
+ if (!owner.closed && processes.some((entry) => entry.pid === owner.pid && entry.started_identity === owner.started_identity))
328
+ continue;
329
+ for (const name of fs.readdirSync(oldDir).filter((name) => name !== "owner.json" && name.endsWith(".json"))) {
330
+ const oldFile = path.join(oldDir, name);
331
+ const file = path.join(this.ownerDir, name);
332
+ // 元のpathが消えるrenameを使うため、同じ旧recordを二つのprocessで回収できない。
333
+ try {
334
+ fs.renameSync(oldFile, file);
335
+ }
336
+ catch (error) {
337
+ if (error.code === "ENOENT")
338
+ continue;
339
+ throw error;
340
+ }
341
+ const record = readRecord(file);
342
+ const job = { record, file, controller: new AbortController() };
343
+ this.jobs.set(record.delivery_id, job);
344
+ if (record.state === "sending") {
345
+ record.state = "unknown";
346
+ record.error = "PARENT_DELIVERY_INTERRUPTED: キュー送信中にMCP processが終了しました。自動再送はしていません";
347
+ this.finish(job);
348
+ }
349
+ else if (record.state === "waiting" || record.state === "ready") {
350
+ try {
351
+ await this.deps.verify(record.parent);
352
+ if (record.state === "waiting")
353
+ this.watch(job);
354
+ else
355
+ job.delivery = this.deliver(job).catch((error) => this.reportServiceError(error));
356
+ }
357
+ catch (error) {
358
+ record.state = "failed";
359
+ record.error = error instanceof Error ? error.message : String(error);
360
+ this.finish(job);
361
+ }
362
+ }
363
+ else
364
+ this.finish(job);
365
+ }
366
+ }
367
+ }
368
+ async close() {
369
+ this.closing = true;
370
+ clearInterval(this.timer);
371
+ await this.recovering;
372
+ await Promise.allSettled(this.registering.values());
373
+ const jobs = [...this.jobs.values()];
374
+ for (const job of jobs)
375
+ if (job.record.state === "waiting")
376
+ job.controller.abort();
377
+ await Promise.all(jobs.map((job) => job.task));
378
+ await Promise.all(jobs.map((job) => job.delivery));
379
+ this.owner.closed = true;
380
+ writeJson0600(path.join(this.ownerDir, "owner.json"), this.owner);
381
+ }
382
+ }