aiterm-mcp 0.51.1 → 0.51.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.51.3] - 2026-10-04
11
+
12
+ ### 修正
13
+
14
+ - 完了待ちが、子の答えを待つ間ずっと、100msごとに`tmux capture-pane`を起動していた(ADR 0085)。利用上限の知らせを、完了の見回りの
15
+ たびに画面から確かめていたため。待ち1本で8秒に76回、4本重なった時はBellTeamのコンテナのCPUが148%だった。画面を読むのは、
16
+ Claude・Grokが2秒に1回、Cursorが1秒に1回(1回の画面で利用上限とhook拒否の両方を見る)にした。直した版は、待ち1本・8秒で
17
+ Claude Codeの子が4回、Cursorの子が8回。完了の見回り(100ms)は変えていない。子が利用上限で止まった時に`rate_limited`を返すのが、
18
+ 最長で2秒(Cursorは1秒)遅れる。
19
+ - 親配送の回収で、pidは居ると出るがOSのprocess表に無い持ち主を、終了として閉じる(ADR 0086)。Windowsでは、終了したprocessの
20
+ pidが、誰かがhandleを握っている間は空かずに残る。0.51.2はこの持ち主を閉じず、この版より前のMCP processが5秒おきに照会し続けていた
21
+ (実物で、3つのMCP processが各々1分に22〜24回)。process表にはあるが開始時刻を読めない持ち主だけ、今までどおり60秒に1回問い合わせる。
22
+ pidが無い持ち主も、この版より前のMCP processが生きていて保存場所を消せない間は、`closed`を書いておく。
23
+ `owner.json`の`closed_reason`に`process_gone`が増える。0.51.2へ戻しても、そのまま動く。
24
+
25
+ ## [0.51.2] - 2026-10-04
26
+
27
+ ### 修正
28
+
29
+ - 親配送の回収が、終了した持ち主の保存場所を残し続け、使い回されたpidを5秒おきにOSへ問い合わせていた(ADR 0084)。
30
+ Windowsでは問い合わせがPowerShellの起動で、MCP process 1つが約2.5秒に1回起こし続けていた(実物で、長く動く4つが85秒に約260回。
31
+ 保存場所は約1,620)。macOSでは`ps`。記録を引き取り終えた保存場所は、自分で閉じた持ち主とpidの無い持ち主のものを消す。
32
+ pidが別のprocessへ使い回された持ち主は`owner.json`を`closed`へ書き換え、どの版の回収にも照合を繰り返させない。
33
+ 開始時刻を照合できない持ち主は60秒に1回だけ問い合わせる。この版より前のMCP processが同じ置き場で生きている間は、保存場所を消さない。
34
+ `owner.json`に`removal_safe`と`closed_reason`が増える。前の版へ戻しても、そのまま動く(前の版はこの2つを読まない)。
35
+ - Windowsの開始時刻の照会が、CommandLineを読めないprocessを結果から落としていた。pidがserviceへ使い回された時に、
36
+ 別のprocessと確かめられなかった。
37
+ - `pty_observe`の`activity.post_startup_process_count`で、Aitermがprocessの一覧を引くために起こすprocess
38
+ (`ps`、WindowsのPowerShellとそのconsole host)を数えない。Windowsで、何もしていない席の数が0と2を行き来していた。
39
+
10
40
  ## [0.51.1] - 2026-10-04
11
41
 
12
42
  ### 修正
@@ -2060,7 +2090,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
2060
2090
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
2061
2091
  provenance.
2062
2092
 
2063
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.1...HEAD
2093
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.3...HEAD
2094
+ [0.51.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.2...v0.51.3
2095
+ [0.51.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.1...v0.51.2
2064
2096
  [0.51.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.0...v0.51.1
2065
2097
  [0.51.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.50.0...v0.51.0
2066
2098
  [0.50.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.49.0...v0.50.0
package/README.ja.md CHANGED
@@ -209,7 +209,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
209
209
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
210
210
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
211
211
 
212
- **状態:** 開発継続中 · 現行公開版 **v0.51.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.51.3** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -520,7 +520,7 @@ agent sessionを閉じると失うものは、次の2項目で分かる。`activ
520
520
  完了した時点(初手を送る前)に居なかったprocessの数。起動直後の1分に始めた裏の作業も数える。数えないものは2つ。Codex自身の
521
521
  補助process(`codex-code-mode-host`)と、中継`mcp-lazy`の直接の子(初めて使う時に起こすMCPの本体と、先行起動の判定)。
522
522
  その下で動くprocessは数える。harnessが立て直した起動時のprocess(終了した起動時のprocessと同じ親の下に、同じargvで立ったもの。
523
- つなぎ直したMCPなど)も数えない。`pending_child_deliveries`は、そのsessionが親として待っていて、まだ届け終えていない
523
+ つなぎ直したMCPなど)も数えない。Aitermがprocessの一覧を引くために起こすprocess(`ps`、WindowsではPowerShellとそのconsole host)も数えない。`pending_child_deliveries`は、そのsessionが親として待っていて、まだ届け終えていない
524
524
  子の結果の数。呼び出した側が誰でも付く。どちらも、分からない時はnull(通常PTY、harnessのprocessを特定できない時。
525
525
  processの数は0.48.0以前が起動したagentも)。別端末が旧版の時は項目ごと無い。nullと項目なしは「分からない」で、0ではない。
526
526
 
package/README.md CHANGED
@@ -223,7 +223,7 @@ collection is off by default and performs no network I/O. It ships via
223
223
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
224
224
  Release re-registers the Official MCP Registry entry.
225
225
 
226
- **Status:** actively maintained · current public release **v0.51.1** · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
226
+ **Status:** actively maintained · current public release **v0.51.3** · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
227
227
 
228
228
  ### Update and rollback
229
229
 
@@ -553,7 +553,8 @@ processes in the session that did not exist when `agent_launch` finished startup
553
553
  started in the first minute is counted too. Two kinds of process are not counted: Codex's own `codex-code-mode-host` helper, and
554
554
  the direct children of an `mcp-lazy` relay (the MCP server it starts on first use and its wake predicate). What runs below them
555
555
  is counted. A startup process that the harness restarted (a process with the same parent and the same arguments as a startup
556
- process that has exited, such as a reconnected MCP server) is not counted either. `pending_child_deliveries`
556
+ process that has exited, such as a reconnected MCP server) is not counted either, and neither are the processes Aiterm itself
557
+ starts to read the process table (`ps`, or PowerShell and its console host on Windows). `pending_child_deliveries`
557
558
  is the number of sub-agent results this session is still waiting for as a parent, whoever the caller is. Both are null when
558
559
  Aiterm cannot tell (ordinary terminals, agents launched by 0.48.0 or earlier for the process count, or an unresolved harness
559
560
  process), and may be absent when a remote host runs an older Aiterm. Treat null or absent as unknown, not as zero.
@@ -85,6 +85,22 @@ export function shq(s) {
85
85
  }
86
86
  export const LAUNCH_ID_RE = /^[0-9a-f]{32}$/;
87
87
  export const AGENT_DONE_POLL_MS = 100;
88
+ // 完了待ちの中で画面を読む間隔。画面を読むたびにtmux(capture-pane)を1回起動するので、完了の見回りとは別に持つ。
89
+ // 画面でしか分からないのは利用上限の知らせとCursorのhook拒否の表示で、どちらも数秒は画面に残る。
90
+ export const AGENT_RATE_LIMIT_POLL_MS = 2_000;
91
+ // Cursorのhook拒否の表示は数秒で消える。見えている間に1回は読む。
92
+ export const CURSOR_SCREEN_POLL_MS = 1_000;
93
+ /** 最初の呼び出しと、前にtrueを返してから間隔が空いた呼び出しだけtrueを返す。 */
94
+ export function pollGate(intervalMs, now = () => performance.now()) {
95
+ let last = -Infinity;
96
+ return () => {
97
+ const at = now();
98
+ if (at - last < intervalMs)
99
+ return false;
100
+ last = at;
101
+ return true;
102
+ };
103
+ }
88
104
  export const AGENT_EVENT_MAX_BYTES = 1024 * 1024;
89
105
  export const AGENT_EVENT_TAIL_BYTES = 64 * 1024;
90
106
  export const CODEX_TRANSCRIPT_INCREMENT_MAX_BYTES = 16 * 1024 * 1024;
package/dist/core.js CHANGED
@@ -16,10 +16,10 @@ import * as rtk from "./rtk.js";
16
16
  import { isCursorMcpClient } from "aiterm-steer-delivery";
17
17
  import { paneTokenHint } from "./harnesses/pane-tokens.js";
18
18
  import { unfinishedDeliveriesOwnedBy } from "./parent-delivery-owners.js";
19
- import { readRuntimeProcesses, processSubtree, parentProcess, processIdentity, backgroundProcesses, lazyRelayProcess } from "./process-runtime.js";
19
+ import { readRuntimeProcesses, processSubtree, parentProcess, processIdentity, backgroundProcesses, lazyRelayProcess, aitermProcessProbe, windowsConsoleHost } from "./process-runtime.js";
20
20
  import { AitermError, telemetryOwnedFailure, ownTelemetryFailure } from "./errors.js";
21
21
  import { isWin, SOCKDIR, tmuxCommand, sendPsmuxPayload, loadPtyBufferChunk, pasteBufferBaseArgs, tmuxNewSession, registeredSessionEnvironment, attachCommand, normalizePaneCommand, atomicShellMultiline, appendMarkSentinel, markShellCommand, settlePaneLog, paneCwdArgument, sessionEnvironmentLaunch, } from "./tmux-runtime.js";
22
- import { sleep, currentUid, runtimeStateBase, safeStatSize, readFileRange, writeJson0600, createEmpty0600, shq, LAUNCH_ID_RE, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, assertSessionName, agentsDir, agentEventPath, agentMetadataPath, writeAgentMetadata, AGENT_EVENT_TAIL_BYTES, agentLabel, agentHarness, subagentInstruction, agentLineageFields, } from "./agent-shared.js";
22
+ import { sleep, currentUid, runtimeStateBase, safeStatSize, readFileRange, writeJson0600, createEmpty0600, shq, LAUNCH_ID_RE, AGENT_DONE_POLL_MS, AGENT_RATE_LIMIT_POLL_MS, pollGate, AGENT_EVENT_MAX_BYTES, assertSessionName, agentsDir, agentEventPath, agentMetadataPath, writeAgentMetadata, AGENT_EVENT_TAIL_BYTES, agentLabel, agentHarness, subagentInstruction, agentLineageFields, } from "./agent-shared.js";
23
23
  import { catalogUnavailable } from "./model-catalog.js";
24
24
  import { grokAuthPlan, grokAuthStatus, grokAuthPane, realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokModelChoices, grokEventsTranscript, latestGrokCompletion, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, grokTuiBusy, grokPaneObservation, grokRateLimitDialog, grokStartupAction, grokLaunchBlockingDialog, assertGrokSandboxNotRejected, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./harnesses/grok.js";
25
25
  import { codexAuthPlan, codexAuthStatus, codexAuthPane, bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexPaneObservation, codexHelperProcess, codexRateLimitModelSwitchDialog, codexApprovalDialog, codexStartupAction, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, codexModelChoices, } from "./harnesses/codex.js";
@@ -1090,7 +1090,7 @@ export function startupProcessShapes(identities, rows) {
1090
1090
  return [{ identity, parent: parent ? processKey(parent) : null, argv_digest: row.argv_digest }];
1091
1091
  });
1092
1092
  }
1093
- // 起動完了の控えに無いprocessの数。harnessと中継が自分のために立てるprocessは、利用者の作業ではないので数えない。
1093
+ // 起動完了の控えに無いprocessの数。harness・中継・Aitermが自分のために立てるprocessは、利用者の作業ではないので数えない。
1094
1094
  // その下で動くprocessは数える。
1095
1095
  // harnessは起動の足場(MCP、language server)を自分で立て直す。控えた後に立て直されると、控えに無いprocessとして残る。
1096
1096
  // 控えの物が終了していて、同じ親の下に同じargvで立ったprocessは、その立て直しとして数えない。
@@ -1101,9 +1101,17 @@ export function postStartupProcessCount(kind, activityRows, rows, startup, shape
1101
1101
  const parent = parentProcess(row, byPid);
1102
1102
  return parent !== undefined && lazyRelayProcess(parent.command);
1103
1103
  };
1104
+ // 席の中のAitermも、配送の持ち主の生存確認でprocess表を引く。その問い合わせと、付いて立つconsole hostは数えない。
1105
+ // 親の問い合わせが先に終わって残ったconsole hostも同じ扱いにする(console hostは最後のconsole processと一緒に消える)。
1106
+ const ownProbe = (row) => {
1107
+ if (aitermProcessProbe(row.command))
1108
+ return true;
1109
+ const parent = parentProcess(row, byPid);
1110
+ return windowsConsoleHost(row) && (parent === undefined || aitermProcessProbe(parent.command));
1111
+ };
1104
1112
  const present = new Set(activityRows.map(processKey));
1105
1113
  const added = new Map(activityRows
1106
- .filter(row => !startup.has(processKey(row)) && !(kind === "codex" && codexHelperProcess(row.command)) && !relayChild(row))
1114
+ .filter(row => !startup.has(processKey(row)) && !(kind === "codex" && codexHelperProcess(row.command)) && !relayChild(row) && !ownProbe(row))
1107
1115
  .map(row => [processKey(row), row]));
1108
1116
  const gone = shapes.filter(shape => startup.has(shape.identity) && !present.has(shape.identity));
1109
1117
  const replacedBy = new Map();
@@ -2754,7 +2762,7 @@ export async function observeAgentDone(name, o = {}) {
2754
2762
  return observeCodexDone(meta, timeout, o.cursor, o.signal);
2755
2763
  }
2756
2764
  if (meta.kind === "cursor" && meta.completion_route === "cursor_transcript") {
2757
- return observeCursorDone(meta, timeout, o.cursor, detectAgentRateLimit, (session) => captureScreen(session, 0), o.signal);
2765
+ return observeCursorDone(meta, timeout, o.cursor, (session) => captureScreen(session, 0), o.signal);
2758
2766
  }
2759
2767
  if (meta.kind === "grok" && meta.completion_route === "grok_transcript") {
2760
2768
  return observeGrokDone(meta, timeout, o.cursor, detectAgentRateLimit, o.signal);
@@ -2772,6 +2780,9 @@ export async function observeAgentDone(name, o = {}) {
2772
2780
  const transcriptFile = claudeSessionTranscriptPath(meta);
2773
2781
  let transcriptCursor = transcriptFile ? safeStatSize(transcriptFile) : 0;
2774
2782
  let transcriptCarry = "";
2783
+ // 利用上限の知らせは画面でしか分からない。画面を読むとtmuxを起動するので、完了の見回りより間隔を空ける。
2784
+ // 実被弾: 完了の見回り(100ms)のたびに読んでいて、待ち1本が1秒に約10回tmuxを起動していた(4本でコンテナのCPUが148%)。
2785
+ const rateLimitDue = pollGate(AGENT_RATE_LIMIT_POLL_MS);
2775
2786
  const observation = (outcome, ev = null, rateLimit = null, apiError = null) => ({
2776
2787
  schema: "aiterm.agent-wait-result.v1",
2777
2788
  session_id: meta.aiterm_session,
@@ -2832,7 +2843,8 @@ export async function observeAgentDone(name, o = {}) {
2832
2843
  }
2833
2844
  // timeout=0 は「待たずに一度だけ見る」照会=未完了は失敗ではなく running。
2834
2845
  // 1秒以上を指定した待機の未完了は従来どおり timeout で、待ち方の意味は変えない。
2835
- {
2846
+ // 最初の周回では必ず見る(timeout=0の照会も1回は見る)。
2847
+ if (rateLimitDue()) {
2836
2848
  const limited = detectAgentRateLimit(meta.kind, meta.aiterm_session);
2837
2849
  if (limited)
2838
2850
  return observation("rate_limited", null, limited);
@@ -8,7 +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 { AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, agentEventPath, agentHarness, agentLineageFields, agentMetadataPath, createEmpty0600, readFileRange, safeStatSize, shq, sleep, subagentInstruction, writeAgentMetadata, writeScopeLaunchNote, } from "../agent-shared.js";
11
+ 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
12
  const CURSOR_TRANSCRIPT_MATCH_MAX_BYTES = 1024 * 1024;
13
13
  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;
14
14
  // Cursor Agentはextended keyboard protocolを有効にするため、通常のtmux Enterではなく
@@ -185,12 +185,14 @@ export function latestCursorCompletion(meta, readTranscriptLines) {
185
185
  ? cursorCompletionEvent(meta, harnessSessionId, state.terminalRecord, `cursor:${state.userTurns}`)
186
186
  : null;
187
187
  }
188
- export async function observeCursorDone(meta, timeout, requestedCursor, detectRateLimit, readScreen, signal) {
188
+ export async function observeCursorDone(meta, timeout, requestedCursor, readScreen, signal) {
189
189
  const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
190
190
  let transcript = cursorTranscript(meta);
191
191
  const startBoundary = requestedCursor ?? (transcript ? cursorTranscriptState(transcript).userTurns : 0);
192
192
  let malformedEvents = 0;
193
193
  const deadline = performance.now() + timeout * 1000;
194
+ // 画面を読むたびにtmuxを1回起動する。完了の見回りより間隔を空け、1回読んだ画面で利用上限とhook拒否の両方を見る。
195
+ const screenDue = pollGate(CURSOR_SCREEN_POLL_MS);
194
196
  const observation = (outcome, ev = null, rateLimit = null, error = null) => ({
195
197
  schema: "aiterm.agent-wait-result.v1",
196
198
  session_id: meta.aiterm_session,
@@ -221,13 +223,17 @@ export async function observeCursorDone(meta, timeout, requestedCursor, detectRa
221
223
  return observation("done", done);
222
224
  }
223
225
  }
224
- const limited = detectRateLimit(meta.kind, meta.aiterm_session);
225
- if (limited)
226
- return observation("rate_limited", null, limited);
227
- // 拒否されたpromptのturnは始まらず、完了も来ない。拒否の表示は数秒で消えるので、見えている間に終わらせる。
228
- const hookBlocked = cursorHookBlocked(readScreen(meta.aiterm_session));
229
- if (hookBlocked)
230
- return observation("error", null, null, `USER_HOOK_BLOCKED: ${hookBlocked.message}`);
226
+ // 最初の周回では必ず読む(timeout=0の照会も1回は読む)。
227
+ if (screenDue()) {
228
+ const screen = readScreen(meta.aiterm_session);
229
+ const limited = cursorUsageLimit(screen)?.message ?? null;
230
+ if (limited)
231
+ return observation("rate_limited", null, limited);
232
+ // 拒否されたpromptのturnは始まらず、完了も来ない。拒否の表示は数秒で消えるので、見えている間に終わらせる。
233
+ const hookBlocked = cursorHookBlocked(screen);
234
+ if (hookBlocked)
235
+ return observation("error", null, null, `USER_HOOK_BLOCKED: ${hookBlocked.message}`);
236
+ }
231
237
  if (performance.now() >= deadline)
232
238
  return observation(timeout === 0 ? "running" : "timeout");
233
239
  await sleep(AGENT_DONE_POLL_MS);
@@ -10,7 +10,7 @@ import { randomBytes, randomUUID } from "node:crypto";
10
10
  import { AitermError } from "../errors.js";
11
11
  import { runAgentProtocolCommand, spawnAgentControlCommand } from "../agent-resolver.js";
12
12
  import { catalogInvalid, catalogUnavailable, checkedCatalog, findJsonLine, processSummary } from "../model-catalog.js";
13
- import { shq, subagentInstruction, writeScopeLaunchNote, safeStatSize, readFileRange, sleep, agentMetadataPath, writeAgentMetadata, agentEventPath, createEmpty0600, agentLineageFields, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, GROK_TRANSCRIPT_INCREMENT_MAX_BYTES, agentHarness, } from "../agent-shared.js";
13
+ import { shq, subagentInstruction, writeScopeLaunchNote, safeStatSize, readFileRange, sleep, agentMetadataPath, writeAgentMetadata, agentEventPath, createEmpty0600, agentLineageFields, AGENT_DONE_POLL_MS, AGENT_RATE_LIMIT_POLL_MS, pollGate, AGENT_EVENT_MAX_BYTES, GROK_TRANSCRIPT_INCREMENT_MAX_BYTES, agentHarness, } from "../agent-shared.js";
14
14
  const GROK_MODELS_MAX_BYTES = 1024 * 1024;
15
15
  const GROK_MODELS_TIMEOUT_MS = 15_000;
16
16
  // grok CLI はモデル未指定だと端末側 default に従うため、ツール契約として既定 slug を固定する。
@@ -229,6 +229,7 @@ export async function observeGrokDone(meta, timeout, requestedCursor, detectRate
229
229
  let discardLeadingFragment = false;
230
230
  let initializedBoundary = false;
231
231
  const deadline = performance.now() + timeout * 1000;
232
+ const rateLimitDue = pollGate(AGENT_RATE_LIMIT_POLL_MS);
232
233
  const observation = (outcome, ev = null, rateLimit = null) => ({
233
234
  schema: "aiterm.agent-wait-result.v1",
234
235
  session_id: meta.aiterm_session,
@@ -293,7 +294,8 @@ export async function observeGrokDone(meta, timeout, requestedCursor, detectRate
293
294
  }
294
295
  }
295
296
  }
296
- {
297
+ // 質問カードの確認は画面を読む(tmuxを起動する)。完了の見回りより間隔を空け、最初の周回では必ず見る。
298
+ if (rateLimitDue()) {
297
299
  const limited = detectRateLimit(meta.kind, meta.aiterm_session);
298
300
  if (limited)
299
301
  return observation("rate_limited", null, limited);
@@ -62,6 +62,8 @@ async function submitParentAnswer(parent, deliveryId, text) {
62
62
  // 5秒おきの確認はpidの存在だけを見て、開始時刻は初めて見た持ち主とこの間隔でだけ照合する。
63
63
  // 持ち主が終了した直後に同じpidが再利用された時だけ、回収が最長でこの間隔だけ遅れる。生きている持ち主の記録は引き取らない。
64
64
  const OWNER_IDENTITY_RECHECK_MS = 60_000;
65
+ // owner.jsonの無い保存場所を、作っている途中の持ち主として扱う時間。
66
+ const OWNER_SETUP_GRACE_MS = 60_000;
65
67
  function processExists(pid) {
66
68
  // 0以下はprocess groupを指す。持ち主のpidにはならない。
67
69
  if (pid <= 0)
@@ -74,6 +76,14 @@ function processExists(pid) {
74
76
  return error.code === "EPERM";
75
77
  }
76
78
  }
79
+ function ownerSetupInProgress(directory) {
80
+ try {
81
+ return Date.now() - fs.statSync(directory).mtimeMs < OWNER_SETUP_GRACE_MS;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
77
87
  function readRecord(file) {
78
88
  try {
79
89
  const record = recordSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
@@ -115,7 +125,7 @@ export class ParentDeliveryManager {
115
125
  registering = new Map();
116
126
  timer;
117
127
  recovering = null;
118
- // 他の持ち主の開始時刻を最後に照合した時刻(持ち主の保存場所ごと)。
128
+ // 他の持ち主の開始時刻を最後に照合した時刻と結果(持ち主の保存場所ごと)。照合できなかった結果も覚える。
119
129
  verifiedOwners = new Map();
120
130
  serviceError = null;
121
131
  closing = false;
@@ -139,9 +149,9 @@ export class ParentDeliveryManager {
139
149
  this.results = path.join(this.root, "results");
140
150
  this.claims = path.join(options.root ?? path.join(stateRoot, prefix + "parent-deliveries"), "claims");
141
151
  const ownProcess = this.deps.processes([process.pid]).find((entry) => entry.pid === process.pid);
142
- if (!ownProcess)
152
+ if (!ownProcess || ownProcess.started_identity === null)
143
153
  throw new AitermError("PARENT_DELIVERY_OWNER_UNKNOWN: 配送processを識別できません", 2);
144
- this.owner = { pid: process.pid, started_identity: ownProcess.started_identity, closed: false };
154
+ this.owner = { pid: process.pid, started_identity: ownProcess.started_identity, closed: false, removal_safe: true };
145
155
  this.ownerDir = path.join(this.active, `${deliveryOwnerPrefix(this.owner)}${randomUUID()}`);
146
156
  fs.mkdirSync(this.ownerDir, { recursive: true, mode: 0o700 });
147
157
  fs.mkdirSync(this.results, { recursive: true, mode: 0o700 });
@@ -361,7 +371,17 @@ export class ParentDeliveryManager {
361
371
  if (!owner.isDirectory())
362
372
  continue;
363
373
  const directory = path.join(active, owner.name);
364
- files.push(...fs.readdirSync(directory).filter((name) => name !== "owner.json" && name.endsWith(".json")).map((name) => path.join(directory, name)));
374
+ let names;
375
+ // 一覧を取った後に、回収が終了した持ち主の保存場所を消す事がある。
376
+ try {
377
+ names = fs.readdirSync(directory);
378
+ }
379
+ catch (error) {
380
+ if (error.code === "ENOENT")
381
+ continue;
382
+ throw error;
383
+ }
384
+ files.push(...names.filter((name) => name !== "owner.json" && name.endsWith(".json")).map((name) => path.join(directory, name)));
365
385
  }
366
386
  }
367
387
  return files;
@@ -399,7 +419,7 @@ export class ParentDeliveryManager {
399
419
  const directories = fs.readdirSync(this.active, { withFileTypes: true })
400
420
  .filter((directory) => directory.isDirectory() && path.join(this.active, directory.name) !== this.ownerDir);
401
421
  const now = this.deps.now();
402
- const alive = new Map();
422
+ const fate = new Map();
403
423
  const unverified = [];
404
424
  for (const directory of directories) {
405
425
  let owner;
@@ -413,46 +433,83 @@ export class ParentDeliveryManager {
413
433
  continue;
414
434
  const pid = owner.pid;
415
435
  if (!this.deps.exists(pid)) {
416
- alive.set(directory.name, false);
436
+ fate.set(directory.name, "gone");
417
437
  continue;
418
438
  }
419
439
  const verified = this.verifiedOwners.get(directory.name);
420
440
  if (verified && verified.pid === pid && verified.started_identity === owner.started_identity
421
441
  && now - verified.at < OWNER_IDENTITY_RECHECK_MS)
422
- alive.set(directory.name, true);
442
+ fate.set(directory.name, verified.alive ? "alive" : "unverified");
423
443
  else
424
444
  unverified.push({ name: directory.name, pid, started_identity: owner.started_identity });
425
445
  }
426
446
  if (unverified.length > 0) {
427
447
  const processes = this.deps.processes([...new Set(unverified.map((entry) => entry.pid))]);
428
448
  for (const entry of unverified) {
429
- const same = processes.some((row) => row.pid === entry.pid && row.started_identity === entry.started_identity);
430
- alive.set(entry.name, same);
431
- if (same)
432
- this.verifiedOwners.set(entry.name, { pid: entry.pid, started_identity: entry.started_identity, at: now });
449
+ const row = processes.find((candidate) => candidate.pid === entry.pid);
450
+ // pidは居ると出たのにprocess表に無いのは、照会の直前に終了したか、終了したprocessの名残
451
+ // (Windowsでは、誰かがhandleを握っている間、終了したprocessのpidが空かず、権限の低いprocessからは「居るが開けない」と見える)。
452
+ const state = !row ? "vanished" : row.started_identity === null ? "unverified"
453
+ : row.started_identity === entry.started_identity ? "alive" : "reused";
454
+ fate.set(entry.name, state);
455
+ // 終了と分かった持ち主は下で閉じるので、照合の結果を覚えない。
456
+ if (state === "alive" || state === "unverified") {
457
+ this.verifiedOwners.set(entry.name, { pid: entry.pid, started_identity: entry.started_identity, at: now, alive: state === "alive" });
458
+ }
433
459
  }
434
460
  }
435
- for (const name of this.verifiedOwners.keys())
436
- if (alive.get(name) !== true)
461
+ for (const name of this.verifiedOwners.keys()) {
462
+ const state = fate.get(name);
463
+ if (state !== "alive" && state !== "unverified")
437
464
  this.verifiedOwners.delete(name);
465
+ }
466
+ // 記録を引き取り終えた、終了した持ち主の保存場所。残すと、回収のたびに全部を読み直す。
467
+ const finished = [];
468
+ // pidが無いと分かった、closeしていない持ち主。保存場所を消せない回は、閉じた事だけを書く。
469
+ const goneOwners = [];
470
+ // owner.jsonの無い、古い保存場所。
471
+ const abandoned = [];
472
+ // 旧版は、読んでいる最中に他の持ち主の保存場所が消えると回収が止まる。印の無い持ち主が生きている間は消さない。
473
+ let removalBlocked = false;
438
474
  for (const directory of directories) {
439
475
  const oldDir = path.join(this.active, directory.name);
476
+ const ownerFile = path.join(oldDir, "owner.json");
440
477
  let owner;
441
478
  try {
442
- owner = JSON.parse(fs.readFileSync(path.join(oldDir, "owner.json"), "utf8"));
479
+ owner = JSON.parse(fs.readFileSync(ownerFile, "utf8"));
443
480
  }
444
481
  catch (error) {
445
- if (error.code === "ENOENT")
446
- continue;
447
- throw new AitermError("PARENT_DELIVERY_OWNER_INVALID: 配送所有者の記録を読めません", 2);
482
+ if (error.code !== "ENOENT")
483
+ throw new AitermError("PARENT_DELIVERY_OWNER_INVALID: 配送所有者の記録を読めません", 2);
484
+ // 作っている途中の持ち主は版が分からない。
485
+ if (ownerSetupInProgress(oldDir))
486
+ removalBlocked = true;
487
+ else
488
+ abandoned.push(oldDir);
489
+ continue;
448
490
  }
449
491
  if (!Number.isSafeInteger(owner.pid) || typeof owner.started_identity !== "string" || typeof owner.closed !== "boolean") {
450
492
  throw new AitermError("PARENT_DELIVERY_OWNER_INVALID: 配送所有者の形式が不正です", 2);
451
493
  }
494
+ const state = fate.get(directory.name);
452
495
  // 生死を確かめた後に現れた持ち主は、次の回で扱う。生きている持ち主の記録は引き取らない。
453
- if (!owner.closed && alive.get(directory.name) !== false)
496
+ if (!owner.closed && (state === undefined || state === "alive")) {
497
+ if (owner.removal_safe !== true)
498
+ removalBlocked = true;
454
499
  continue;
455
- for (const name of fs.readdirSync(oldDir).filter((name) => name !== "owner.json" && name.endsWith(".json"))) {
500
+ }
501
+ if (!owner.closed && state === "unverified" && owner.removal_safe !== true)
502
+ removalBlocked = true;
503
+ let names;
504
+ try {
505
+ names = fs.readdirSync(oldDir);
506
+ }
507
+ catch (error) {
508
+ if (error.code === "ENOENT")
509
+ continue;
510
+ throw error;
511
+ }
512
+ for (const name of names.filter((name) => name !== "owner.json" && name.endsWith(".json"))) {
456
513
  const oldFile = path.join(oldDir, name);
457
514
  const file = path.join(this.ownerDir, name);
458
515
  // 元のpathが消えるrenameを使うため、同じ旧recordを二つのprocessで回収できない。
@@ -490,9 +547,53 @@ export class ParentDeliveryManager {
490
547
  else
491
548
  this.finish(job);
492
549
  }
550
+ if (!owner.closed && (state === "reused" || state === "vanished")) {
551
+ // 同じpidと開始時刻の組は二度と現れない。閉じた事にして、どの版の回収にも照合を繰り返させない。
552
+ // 書けなかった時は次の回でもう一度照合する。配送には影響しない。
553
+ const reason = state === "reused" ? "pid_reused" : "process_gone";
554
+ try {
555
+ writeJson0600(ownerFile, { ...owner, closed: true, closed_reason: reason });
556
+ }
557
+ catch { /* 次の回 */ }
558
+ continue;
559
+ }
560
+ // 持ち主が自分で閉じた保存場所と、pidがもう無い持ち主の保存場所だけを消す。
561
+ // 回収側が閉じた保存場所は、そのpidが空くまで残す。
562
+ const ended = owner.closed ? owner.closed_reason === undefined || !this.deps.exists(owner.pid) : state === "gone";
563
+ if (ended)
564
+ finished.push(oldDir);
565
+ if (!owner.closed && state === "gone")
566
+ goneOwners.push({ file: ownerFile, owner });
567
+ }
568
+ if (removalBlocked) {
569
+ // 消せない間も、終了した持ち主は閉じておく。pidの有無は見る側の権限で変わる事があり(ADR 0086)、
570
+ // 閉じていない持ち主は、前の版の回収が照会の相手にし続ける。
571
+ for (const { file, owner } of goneOwners) {
572
+ try {
573
+ writeJson0600(file, { ...owner, closed: true, closed_reason: "process_gone" });
574
+ }
575
+ catch { /* 次の回 */ }
576
+ }
577
+ return;
578
+ }
579
+ // 片付けの失敗は配送に影響しない。残った保存場所は次の回でもう一度消す。
580
+ for (const directory of finished) {
581
+ try {
582
+ fs.rmSync(directory, { recursive: true, force: true });
583
+ }
584
+ catch { /* 次の回 */ }
585
+ }
586
+ for (const directory of abandoned) {
587
+ try {
588
+ fs.rmdirSync(directory);
589
+ }
590
+ catch { /* 空でない、または既に無い */ }
493
591
  }
494
592
  }
495
593
  async close() {
594
+ // 閉じた後の保存場所は、記録を引き取った他の持ち主が消す。二度目のcloseは何も書かない。
595
+ if (this.owner.closed)
596
+ return;
496
597
  this.closing = true;
497
598
  clearInterval(this.timer);
498
599
  await this.recovering;
@@ -32,9 +32,32 @@ export function parsePosixProcessTable(text) {
32
32
  };
33
33
  });
34
34
  }
35
+ // AitermがOSのprocess表を引く時のコマンドの形。席のprocessを数える側が、これを利用者の作業と取り違えないために使う。
36
+ const POSIX_PS = "/bin/ps";
37
+ const POSIX_TABLE_ARGS = ["-axww", "-o", "pid=,ppid=,pgid=,stat=,lstart=,time=,command="];
38
+ const POSIX_IDENTITY_ARGS = ["-o", "pid=,lstart=", "-p"];
39
+ const WINDOWS_PROBE_HEADER = ["$ErrorActionPreference='Stop'", "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)"];
40
+ const WINDOWS_PROBE_ARGS = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand"];
41
+ // 先頭2行だけのbase64。3 byteの区切りで切り、後ろの行に左右されない前方一致にする。
42
+ const WINDOWS_PROBE_PREFIX = (() => {
43
+ const bytes = Buffer.from(WINDOWS_PROBE_HEADER.join("\n") + "\n", "utf16le");
44
+ return bytes.subarray(0, bytes.length - bytes.length % 3).toString("base64");
45
+ })();
46
+ /** Aitermがprocess表を引くために起こしたprocessか。引く側のAitermは、数えられる席の中のMCP processでもある。 */
47
+ export function aitermProcessProbe(command) {
48
+ if (command.includes(` ${WINDOWS_PROBE_ARGS.join(" ")} ${WINDOWS_PROBE_PREFIX}`))
49
+ return true;
50
+ return command === `${POSIX_PS} ${POSIX_TABLE_ARGS.join(" ")}` || command.startsWith(`${POSIX_PS} ${POSIX_IDENTITY_ARGS.join(" ")} `);
51
+ }
52
+ /** Windowsのconsole host。console processの起動に付いて立ち、最後のconsole processが終わると消える。 */
53
+ export function windowsConsoleHost(row) {
54
+ const first = /^(?:"([^"]+)"|(\S+))/.exec(row.command);
55
+ const name = (row.executable || first?.[1] || first?.[2] || "").replace(/\\/g, "/");
56
+ return path.posix.basename(name).toLowerCase() === "conhost.exe";
57
+ }
35
58
  export function readRuntimeProcesses() {
36
59
  if (!isWin) {
37
- const result = spawnSync("/bin/ps", ["-axww", "-o", "pid=,ppid=,pgid=,stat=,lstart=,time=,command="], {
60
+ const result = spawnSync(POSIX_PS, POSIX_TABLE_ARGS, {
38
61
  encoding: "utf8", env: { ...process.env, LC_ALL: "C" }, timeout: 10000, maxBuffer: 16 * 1024 * 1024,
39
62
  });
40
63
  if (result.error || result.status !== 0)
@@ -42,13 +65,12 @@ export function readRuntimeProcesses() {
42
65
  return parsePosixProcessTable(result.stdout);
43
66
  }
44
67
  const script = [
45
- "$ErrorActionPreference='Stop'",
46
- "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)",
68
+ ...WINDOWS_PROBE_HEADER,
47
69
  "@(Get-CimInstance Win32_Process | Where-Object { $null -ne $_.CreationDate -and $null -ne $_.CommandLine } | ForEach-Object {",
48
70
  "[ordered]@{ pid=[int]$_.ProcessId; parent_pid=[int]$_.ParentProcessId; executable=[string]$_.ExecutablePath; started_identity=$_.CreationDate.ToUniversalTime().ToString('o'); command=$_.CommandLine; cpu_seconds=([double]$_.KernelModeTime+[double]$_.UserModeTime)/10000000 }",
49
71
  "}) | ConvertTo-Json -Compress",
50
72
  ].join("\n");
51
- const result = spawnSync(resolveWindowsPowerShell7(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
73
+ const result = spawnSync(resolveWindowsPowerShell7(), [...WINDOWS_PROBE_ARGS, Buffer.from(script, "utf16le").toString("base64")], {
52
74
  encoding: "utf8", timeout: 15000, maxBuffer: 16 * 1024 * 1024, windowsHide: true,
53
75
  });
54
76
  if (result.error || result.status !== 0)
@@ -78,6 +100,7 @@ export function readRuntimeProcesses() {
78
100
  }
79
101
  /**
80
102
  * 指定したpidの開始時刻だけを引く。readRuntimeProcessesと同じ開始時刻を返し、存在しないpidは結果に含めない。
103
+ * process表にpidはあるが開始時刻を読めない時は、started_identityをnullで返す(pidが無いのとは区別する)。
81
104
  * 全processのargvを読む一覧取得は、processの多い端末で重い(定期実行から呼ばない)。
82
105
  */
83
106
  export function readProcessIdentities(pids) {
@@ -87,7 +110,7 @@ export function readProcessIdentities(pids) {
87
110
  if (wanted.some(pid => !Number.isSafeInteger(pid) || pid < 0))
88
111
  throw new AitermError("process照会のpidが不正です", 2);
89
112
  if (!isWin) {
90
- const result = spawnSync("/bin/ps", ["-o", "pid=,lstart=", "-p", wanted.join(",")], {
113
+ const result = spawnSync(POSIX_PS, [...POSIX_IDENTITY_ARGS, wanted.join(",")], {
91
114
  encoding: "utf8", env: { ...process.env, LC_ALL: "C" }, timeout: 10000,
92
115
  });
93
116
  // 該当するprocessが一つも無い時、psは何も出さずstatus 1で終わる。
@@ -101,15 +124,16 @@ export function readProcessIdentities(pids) {
101
124
  return { pid: Number(match[1]), started_identity: match[2].trim() };
102
125
  });
103
126
  }
127
+ // CommandLineの読めないprocess(別の権限のserviceなど)も返す。pidが使い回された先を「別のprocess」と確かめるのに要る。
128
+ // 開始時刻を読めない行も、pidがprocess表にある印として返す。
104
129
  const script = [
105
- "$ErrorActionPreference='Stop'",
106
- "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)",
107
- `$rows=@(Get-CimInstance Win32_Process -Filter "${wanted.map(pid => `ProcessId=${pid}`).join(" OR ")}" | Where-Object { $null -ne $_.CreationDate -and $null -ne $_.CommandLine } | ForEach-Object {`,
108
- "[ordered]@{ pid=[int]$_.ProcessId; started_identity=$_.CreationDate.ToUniversalTime().ToString('o') }",
130
+ ...WINDOWS_PROBE_HEADER,
131
+ `$rows=@(Get-CimInstance Win32_Process -Filter "${wanted.map(pid => `ProcessId=${pid}`).join(" OR ")}" | ForEach-Object {`,
132
+ "[ordered]@{ pid=[int]$_.ProcessId; started_identity=$(if ($null -ne $_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { $null }) }",
109
133
  "})",
110
134
  "ConvertTo-Json -Compress -InputObject $rows",
111
135
  ].join("\n");
112
- const result = spawnSync(resolveWindowsPowerShell7(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
136
+ const result = spawnSync(resolveWindowsPowerShell7(), [...WINDOWS_PROBE_ARGS, Buffer.from(script, "utf16le").toString("base64")], {
113
137
  encoding: "utf8", timeout: 15000, windowsHide: true,
114
138
  });
115
139
  if (result.error || result.status !== 0)
@@ -124,8 +148,11 @@ export function readProcessIdentities(pids) {
124
148
  if (!Array.isArray(rows))
125
149
  throw new AitermError("Windows process一覧が配列ではありません", 2);
126
150
  return rows.map(row => {
127
- if (!row || !Number.isSafeInteger(row.pid) || typeof row.started_identity !== "string"
128
- || !Number.isFinite(Date.parse(row.started_identity)))
151
+ if (!row || !Number.isSafeInteger(row.pid))
152
+ throw new AitermError("Windows process一覧のfieldが不正です", 2);
153
+ if (row.started_identity === null)
154
+ return { pid: row.pid, started_identity: null };
155
+ if (typeof row.started_identity !== "string" || !Number.isFinite(Date.parse(row.started_identity)))
129
156
  throw new AitermError("Windows process一覧のfieldが不正です", 2);
130
157
  return { pid: row.pid, started_identity: new Date(row.started_identity).toISOString() };
131
158
  });
package/docs/DESIGN.md CHANGED
@@ -56,6 +56,7 @@ sessionを閉じると失うものは数で返す(ADR 0080)。`activity.post
56
56
  `pending_child_deliveries`はそのsessionが親として待つ未配送の数。数えられない時はnullで、0と区別する。
57
57
  harnessと中継が自分のために立てるprocessは数えない(Codexの`codex-code-mode-host`、中継`mcp-lazy`の直接の子。ADR 0082)。その下は数える。
58
58
  harnessが同じ親の下に同じargvで立て直した起動時のprocessも数えない(ADR 0083)。
59
+ Aitermがprocess表を引くために起こしたprocess(`ps`、WindowsのPowerShellと付いて立つconsole host)も数えない(ADR 0084)。
59
60
  中継の後ろで眠っている本体を配送の引き取りのために起こすかは、`aiterm-delivery-wake`が返す(ADR 0082)。
60
61
  Claude Codeの入力待ちは、起動時の見出しが取得範囲から流れ出た後も、入力欄の形(`❯`行の上下の罫線)で読む。
61
62
  画面本文とargv本文は返さず、活動cursorには画面digestとprocess別CPUだけを持たせる。
@@ -151,6 +152,10 @@ Codexの配送記録と本文はAiterm stateの`parent-deliveries`へ保存す
151
152
  受信口が明示拒否した場合は`failed`、子の異常終了はそのoutcomeを配送する。
152
153
  生存の確認は5秒おきにpidの存在だけをOSへ聞き、開始識別子の照合は初めて見たownerと60秒に1回だけ行う。
153
154
  全processの一覧は取らない。終了直後に同じpidが再利用された時だけ、引き継ぎが最長60秒遅れる(ADR 0078)。
155
+ pidが別のprocessへ使い回されたownerと、pidは居ると出るがprocess表に無いownerは、回収側が`owner.json`を`closed`へ書き換え、照合を繰り返さない。
156
+ process表にはあるが開始時刻を読めないownerだけ、60秒に1回問い合わせる(ADR 0086)。
157
+ 記録を引き継ぎ終えた保存場所は、自分で閉じたownerとpidの無いownerのものを消す。保存場所が消えても読める印(`removal_safe`)の無いownerが
158
+ 同じ置き場で生きている間は消さない(ADR 0084)。
154
159
  このstateは既存のPTY/harness stateと独立し、旧版は配送を再開しない。
155
160
 
156
161
  ### Claude Code親への自動配送
@@ -320,6 +325,8 @@ Grokの終了済みerrorターンにweekly-limit質問カードが残る場合
320
325
  `grokRateLimitDialog`が見出しと操作footerの組を所有し、過去logや後続UIのあるカードは採用しない。
321
326
  privacy案内は現在の枠付きcomposerとmodel footerが見える場合だけ入力受付を妨げない。
322
327
  完了観測は成功eventを優先し、今回のerror eventと現在のカードが揃えばturn情報付きの`rate_limited`を返す。
328
+ 完了の見回りは100msで、画面を読む確認(利用上限の知らせ、Cursorのhook拒否の表示)は別の間隔で行う。画面を読むたびにtmuxを1回起動するため。
329
+ Claude・Grokは2秒、Cursorは1秒に1回で、待ちに入った最初の周回では必ず読む(ADR 0085)。
323
330
  新turnの完了前に古いlogだけで上限を返さない。購入・再認証・過去prompt再送・定期再試行は行わない。
324
331
  画面判定と模擬CLIの実PTY試験は`test/grok-rate-limit.test.mjs`に置く。
325
332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.51.1",
3
+ "version": "0.51.3",
4
4
  "mcpName": "io.github.kitepon/aiterm-mcp",
5
5
  "description": "Persistent terminal MCP with one harness-based launcher for Claude Code, Codex CLI, Grok CLI, and Cursor Agent CLI, plus durable PTYs for SSH, containers, and REPLs.",
6
6
  "keywords": [