aiterm-mcp 0.51.2 → 0.52.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/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.52.0] - 2026-10-04
11
+
12
+ ### 修正
13
+
14
+ - 席の中で起動した試験が`AITERM_STATE_BASE`を本番から引き継ぎ、`killAll`で他のsocketのagent登録まで
15
+ 削除していた(ADR 0087)。BellTeamで複数席の送信が通常PTYの`mode=sent`となり、完了待ちと回答の表示が
16
+ 失敗した。`killAll`は現在のsocketのsessionと控えに属する登録だけを消し、他のsocketの待機lockも保持する。
17
+ 試験入口は本番の保存場所・系譜・tmux環境を外して一時領域を指定する。既に失った登録は対象sessionを
18
+ 閉じて同じIDで起動し直して復旧する。登録の形式と公開toolの返却形式は変えていない。
19
+ - `pty_send`に`require_agent:true`を追加した。agent登録が無いsessionには打鍵せず、
20
+ `AGENT_SESSION_REQUIRED`と「文字列は送信していません」を返す。連携元は、登録消失後の二重送信を避けて復旧できる。
21
+ 省略時は従来の通常PTY送信を維持する。`force:true`との併用は未送信で拒否する。
22
+
23
+ ## [0.51.3] - 2026-10-04
24
+
25
+ ### 修正
26
+
27
+ - 完了待ちが、子の答えを待つ間ずっと、100msごとに`tmux capture-pane`を起動していた(ADR 0085)。利用上限の知らせを、完了の見回りの
28
+ たびに画面から確かめていたため。待ち1本で8秒に76回、4本重なった時はBellTeamのコンテナのCPUが148%だった。画面を読むのは、
29
+ Claude・Grokが2秒に1回、Cursorが1秒に1回(1回の画面で利用上限とhook拒否の両方を見る)にした。直した版は、待ち1本・8秒で
30
+ Claude Codeの子が4回、Cursorの子が8回。完了の見回り(100ms)は変えていない。子が利用上限で止まった時に`rate_limited`を返すのが、
31
+ 最長で2秒(Cursorは1秒)遅れる。
32
+ - 親配送の回収で、pidは居ると出るがOSのprocess表に無い持ち主を、終了として閉じる(ADR 0086)。Windowsでは、終了したprocessの
33
+ pidが、誰かがhandleを握っている間は空かずに残る。0.51.2はこの持ち主を閉じず、この版より前のMCP processが5秒おきに照会し続けていた
34
+ (実物で、3つのMCP processが各々1分に22〜24回)。process表にはあるが開始時刻を読めない持ち主だけ、今までどおり60秒に1回問い合わせる。
35
+ pidが無い持ち主も、この版より前のMCP processが生きていて保存場所を消せない間は、`closed`を書いておく。
36
+ `owner.json`の`closed_reason`に`process_gone`が増える。0.51.2へ戻しても、そのまま動く。
37
+
10
38
  ## [0.51.2] - 2026-10-04
11
39
 
12
40
  ### 修正
@@ -2075,7 +2103,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
2075
2103
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
2076
2104
  provenance.
2077
2105
 
2078
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.2...HEAD
2106
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.52.0...HEAD
2107
+ [0.52.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.3...v0.52.0
2108
+ [0.51.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.2...v0.51.3
2079
2109
  [0.51.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.1...v0.51.2
2080
2110
  [0.51.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.51.0...v0.51.1
2081
2111
  [0.51.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.50.0...v0.51.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.2** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.52.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -259,6 +259,8 @@ pty_read(id, { wait: true }) → 削減済みの出力を読む(完了
259
259
 
260
260
  ClaudeがAPIエラーや安全判定の拒否で終了した時は、Stop hookが発火しなくても次の`pty_send`を新しいturnとして扱う。現在のturn開始後のエラー記録だけを確認し、過去のエラーで実行中のturnを解除しない。上流の拒否はエラーのまま返す。
261
261
 
262
+ agent配送だけを期待する連携では、`pty_send`に`require_agent:true`を指定する。agent登録が無ければ、打鍵前に`AGENT_SESSION_REQUIRED`と「文字列は送信していません」を返す。省略時は通常PTY送信を維持する。`force:true`との併用は未送信で拒否する。
263
+
262
264
  `agent_launch`・`pty_send`(agent session宛て)は任意の`image`(画像ファイルの絶対パスの配列。png/jpg/jpeg/gif/webp)を受ける。aitermが本文末尾へ添付行を付け、どのharnessも自分のfile読取toolでそのpathを画像として開く。呼出し側はharness別の添付手順を覚えない。不正なpathは送信前に拒否する。
263
265
 
264
266
  `agent_launch`は任意の`write_scope`も受ける。Codex/Grokのread-onlyは`--sandbox read-only`、Cursorは公式`--mode ask`で実効化する。path説明は同等CLI引数がないためdeclaration-only。
@@ -550,7 +552,7 @@ Claudeの相関済み承認は既存の`claude_approval`を使う。
550
552
  | ツール | 役割 | 主な引数 |
551
553
  | --- | --- | --- |
552
554
  | `pty_open` | 端末を1個開き`session_id`を返す | `name?`, `shell?`, `env_vars?` |
553
- | `pty_send` | テキストを送る。agent sessionでは子のturnが実行中なら現在のturnへ差し込み(`agent_steer`)、それ以外は非ブロックdispatchとして`event_cursor`を返す(`agent_dispatch`)。差し込みでGrokが待ち行列へ入れない時とCursorの入力欄に残った時は失敗する | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
555
+ | `pty_send` | テキストを送る。agent sessionでは子のturnが実行中なら現在のturnへ差し込み(`agent_steer`)、それ以外は非ブロックdispatchとして`event_cursor`を返す(`agent_dispatch`)。差し込みでGrokが待ち行列へ入れない時とCursorの入力欄に残った時は失敗する | `session_id`, `text`, `enter=true`, `mark`, `force`, `require_agent=false`, `rtk`, `raw` |
554
556
  | `pty_read` | 出力を削減して読む(既定は増分) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk`, `agent_transcript`, `operation_id` |
555
557
  | `pty_key` | 制御キーを送る | `session_id`, `key`(`C-c`/`Enter`/`Up`…) |
556
558
  | `pty_close` | 冪等に閉じ、`closed` / `already_closed`を返す | `session_id` |
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.2** · 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.52.0** · 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
 
@@ -280,6 +280,8 @@ The same primitive hosts another agent's TUI. `agent_launch` starts a selected e
280
280
 
281
281
  起動結果には正規`harness`を含む`aiterm.agent-launch-result.v1`が付き、旧`provider`は互換fieldとして残る。同じ`harness`はagent dispatch、`aiterm-wait`、`agent_configure`、`pty_list`にも載る。Codexは通常rollout、Grokは通常session event、Claudeはlaunch固有Stop hook、Cursorは通常agent transcriptの`turn_ended`を完了正本に使う。agentへの送信は`pty_send`だけで行い、Aitermが送る時点で子の状態を見て振り分ける。Claudeは画面の実行中表示ではなくStopまで残るturnの印で判定する。実行中のturnへは差し込み(`mode=agent_steer`、新しい`event_cursor`と配送は作らない)、それ以外は非ブロックdispatch(`mode=agent_dispatch`)で、harnessごとの完了境界を表す整数`event_cursor`を返す。Codex親は選択に応じて公式Steerまたはqueue、Claude Code親は公式非同期hookで本文を自動受信する。他の親は[`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait)を使う。CursorのsubmitはadapterがCLIのextended keyboard protocolへ変換し、送信本文がcomposerへ残る場合は明示errorにする。
282
282
 
283
+ Set `require_agent:true` on `pty_send` when the integration requires agent delivery. If the agent registration is missing, Aiterm refuses before sending any text and returns `AGENT_SESSION_REQUIRED` with an explicit unsent message. The default preserves ordinary PTY sends; combining it with `force:true` is rejected before sending.
284
+
283
285
  `agent_launch` and `pty_send` (to an agent session) accept an optional `image`: an array of absolute paths to image files (png/jpg/jpeg/gif/webp). Aiterm appends an attachment block to the prompt, and every harness opens the path with its own file-reading tool and sees the image; the caller never learns harness-specific attachment tricks. Invalid paths are rejected before anything is sent.
284
286
 
285
287
  `agent_launch` accepts an optional `write_scope`: either `"read-only"` or a human-readable description of writable paths. Codex/Grok use `--sandbox read-only`; Cursor uses its official read-only `--mode ask`. A path description remains declaration-only because these CLI launch surfaces provide no equivalent path allowlist flag.
@@ -587,7 +589,7 @@ continue to use `claude_approval`.
587
589
  | Tool | Role | Key args |
588
590
  | --- | --- | --- |
589
591
  | `pty_open` | Open one terminal and return a `session_id` | `name?`, `shell?`, `env_vars?` |
590
- | `pty_send` | Send text. On an agent session Aiterm picks the route when it sends: if the child's turn is running, it steers the text into that turn (`agent_steer`); otherwise it is a non-blocking **dispatch** returning an `event_cursor` (`agent_dispatch`). Steering fails when Grok does not queue the text or Cursor leaves it in the composer | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
592
+ | `pty_send` | Send text. On an agent session Aiterm picks the route when it sends: if the child's turn is running, it steers the text into that turn (`agent_steer`); otherwise it is a non-blocking **dispatch** returning an `event_cursor` (`agent_dispatch`). Steering fails when Grok does not queue the text or Cursor leaves it in the composer | `session_id`, `text`, `enter=true`, `mark`, `force`, `require_agent=false`, `rtk`, `raw` |
591
593
  | `pty_read` | Read output, token-reduced (incremental by default) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk`, `agent_transcript`, `operation_id` |
592
594
  | `pty_key` | Send a control key | `session_id`, `key` (`C-c`/`Enter`/`Up`…) |
593
595
  | `pty_close` | Close idempotently; return `closed` / `already_closed` | `session_id` |
@@ -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
@@ -19,7 +19,7 @@ import { unfinishedDeliveriesOwnedBy } from "./parent-delivery-owners.js";
19
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";
@@ -1370,9 +1370,33 @@ export function closeSessionResult(name) {
1370
1370
  };
1371
1371
  }
1372
1372
  export function killAll() {
1373
+ // agentsはAITERM_STATE_BASEを共有する別socketの席も含み得る。終了対象はこのsocketから確定する。
1374
+ const sessions = new Set();
1375
+ const listed = tmux("list-sessions", "-F", "#{session_name}");
1376
+ if (listed.code !== 0 && !/no server running|No such file or directory|failed to connect/i.test(listed.stderr)) {
1377
+ throw new AitermError("killAllの対象sessionを取得できません: " + listed.stderr.trim(), 2);
1378
+ }
1379
+ if (listed.code === 0)
1380
+ for (const name of listed.stdout.trim().split(/\r?\n/).filter(Boolean)) {
1381
+ assertSessionName(name);
1382
+ sessions.add(name);
1383
+ }
1384
+ // serverが外から終了した時の控えも、このsocketの中に残る名前だけ片付ける。
1385
+ let socketFiles = [];
1386
+ try {
1387
+ socketFiles = fs.readdirSync(SOCKDIR);
1388
+ }
1389
+ catch {
1390
+ /* socketの置き場が無い */
1391
+ }
1392
+ for (const file of socketFiles) {
1393
+ const name = file.replace(/\.(?:log|offset|lastcmd|mark|send\.lock)$/, "");
1394
+ if (name !== file && /^[A-Za-z0-9_-]{1,64}$/.test(name))
1395
+ sessions.add(name);
1396
+ }
1373
1397
  {
1374
1398
  // 別プロセスの待機(file lock が生きているもの)も巻き添えにしない
1375
- const foreign = liveWaitLocks(null);
1399
+ const foreign = liveWaitLocks(null).filter((lock) => sessions.has(lock.session));
1376
1400
  if (foreign.length > 0) {
1377
1401
  const list = foreign.map((d) => `${d.session}${d.pid != null ? `(pid ${d.pid})` : ""}`).join(",");
1378
1402
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${list}`, 2);
@@ -1385,7 +1409,10 @@ export function killAll() {
1385
1409
  throw new AitermError(`送信中の session があるため killAll できません: ${list}`, 2);
1386
1410
  }
1387
1411
  }
1388
- tmux("kill-server");
1412
+ const killed = tmux("kill-server");
1413
+ if (killed.code !== 0 && !/no server running|No such file or directory|failed to connect/i.test(killed.stderr)) {
1414
+ throw new AitermError("このsocketのserverを終了できません: " + killed.stderr.trim(), 2);
1415
+ }
1389
1416
  // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark/.send.lock 残骸も掃除する。
1390
1417
  try {
1391
1418
  for (const f of fs.readdirSync(SOCKDIR)) {
@@ -1402,37 +1429,8 @@ export function killAll() {
1402
1429
  catch {
1403
1430
  /* SOCKDIR 不在等は無視 */
1404
1431
  }
1405
- const adir = existingAgentsDir();
1406
- if (adir) {
1407
- try {
1408
- for (const f of fs.readdirSync(adir)) {
1409
- if (f.endsWith(".agent.json") ||
1410
- f.endsWith(".events.jsonl") ||
1411
- f.endsWith(".wait.lock") ||
1412
- f.endsWith(".claude-settings.json") ||
1413
- f.endsWith(".claude-mcp.json") ||
1414
- f.endsWith(".claude-result.json") ||
1415
- f.endsWith(".claude-operation.json") ||
1416
- f.endsWith(".claude-dispatch") ||
1417
- f.endsWith(".interim.json") ||
1418
- f.endsWith(".auth.json") ||
1419
- f.endsWith(".codex-home") ||
1420
- f.endsWith(".grok-home") ||
1421
- f.endsWith(".home")) {
1422
- try {
1423
- fs.rmSync(path.join(adir, f), { recursive: true, force: true });
1424
- }
1425
- catch {
1426
- /* noop */
1427
- }
1428
- }
1429
- }
1430
- }
1431
- catch {
1432
- /* agent state dir 不在等は無視 */
1433
- }
1434
- }
1435
- agentMetadataNegativeCache.clear();
1432
+ for (const name of sessions)
1433
+ cleanupAgentState(name);
1436
1434
  return "killed all sessions on this socket";
1437
1435
  }
1438
1436
  function readAgentLineageSeed() {
@@ -2762,7 +2760,7 @@ export async function observeAgentDone(name, o = {}) {
2762
2760
  return observeCodexDone(meta, timeout, o.cursor, o.signal);
2763
2761
  }
2764
2762
  if (meta.kind === "cursor" && meta.completion_route === "cursor_transcript") {
2765
- return observeCursorDone(meta, timeout, o.cursor, detectAgentRateLimit, (session) => captureScreen(session, 0), o.signal);
2763
+ return observeCursorDone(meta, timeout, o.cursor, (session) => captureScreen(session, 0), o.signal);
2766
2764
  }
2767
2765
  if (meta.kind === "grok" && meta.completion_route === "grok_transcript") {
2768
2766
  return observeGrokDone(meta, timeout, o.cursor, detectAgentRateLimit, o.signal);
@@ -2780,6 +2778,9 @@ export async function observeAgentDone(name, o = {}) {
2780
2778
  const transcriptFile = claudeSessionTranscriptPath(meta);
2781
2779
  let transcriptCursor = transcriptFile ? safeStatSize(transcriptFile) : 0;
2782
2780
  let transcriptCarry = "";
2781
+ // 利用上限の知らせは画面でしか分からない。画面を読むとtmuxを起動するので、完了の見回りより間隔を空ける。
2782
+ // 実被弾: 完了の見回り(100ms)のたびに読んでいて、待ち1本が1秒に約10回tmuxを起動していた(4本でコンテナのCPUが148%)。
2783
+ const rateLimitDue = pollGate(AGENT_RATE_LIMIT_POLL_MS);
2783
2784
  const observation = (outcome, ev = null, rateLimit = null, apiError = null) => ({
2784
2785
  schema: "aiterm.agent-wait-result.v1",
2785
2786
  session_id: meta.aiterm_session,
@@ -2840,7 +2841,8 @@ export async function observeAgentDone(name, o = {}) {
2840
2841
  }
2841
2842
  // timeout=0 は「待たずに一度だけ見る」照会=未完了は失敗ではなく running。
2842
2843
  // 1秒以上を指定した待機の未完了は従来どおり timeout で、待ち方の意味は変えない。
2843
- {
2844
+ // 最初の周回では必ず見る(timeout=0の照会も1回は見る)。
2845
+ if (rateLimitDue()) {
2844
2846
  const limited = detectAgentRateLimit(meta.kind, meta.aiterm_session);
2845
2847
  if (limited)
2846
2848
  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);
package/dist/index.js CHANGED
@@ -273,6 +273,7 @@ registerRemoteAwareTool("pty_send", {
273
273
  "receipt の event_cursor を返す。" +
274
274
  NON_BLOCKING_RULE +
275
275
  "自動配送以外の結果回収は pty_read(agent_transcript:true)、Claude の durable turn は claude_turn を使う。" +
276
+ "require_agent:true はagent登録が無いsessionへ打鍵せず、AGENT_SESSION_REQUIREDで未送信を返す。" +
276
277
  "force:true は非Claude agent sessionへの手動介入用の素送信。aiterm相関付きClaudeの承認UIはclaude_approvalを使う。",
277
278
  inputSchema: {
278
279
  session_id: z.string(),
@@ -291,6 +292,7 @@ registerRemoteAwareTool("pty_send", {
291
292
  .boolean()
292
293
  .default(false)
293
294
  .describe("非Claude agent sessionでは自動dispatchせず素送信する。aiterm相関付きClaudeのactive turnには使えない"),
295
+ require_agent: z.boolean().default(false).describe("agent登録が無いsessionは打鍵前に拒否する。force:trueとは併用できない"),
294
296
  rtk: z.boolean().default(false).describe("既知コマンドを rtk 形へ委譲して送る(rtk 不在なら素通し)"),
295
297
  raw: z.boolean().default(false).describe("送信前サニタイズを無効化"),
296
298
  image: z
@@ -315,10 +317,17 @@ registerRemoteAwareTool("pty_send", {
315
317
  // dispatch前に行った pane 入力の回復("fg" / "fg_stopped" / "stty_raw")。通常送信では省略(additive)。
316
318
  pane_input_recovery: z.array(z.string()).optional(),
317
319
  },
318
- }, async ({ session_id, text, enter, mark, force, rtk, raw, image }, extra) => {
320
+ }, async ({ session_id, text, enter, mark, force, require_agent, rtk, raw, image }, extra) => {
319
321
  let delivery = null;
320
322
  try {
321
- if (!force && core.isAgentSession(session_id)) {
323
+ if (require_agent && force) {
324
+ throw new Error("require_agent:true は force:true と併用できません。文字列は送信していません。");
325
+ }
326
+ const agentSession = !force && core.isAgentSession(session_id);
327
+ if (require_agent && !agentSession) {
328
+ throw new Error(`AGENT_SESSION_REQUIRED: session '${session_id}' のagent登録がありません。文字列は送信していません。`);
329
+ }
330
+ if (agentSession) {
322
331
  if (enter === false)
323
332
  throw new Error("agent session への dispatch は enter:false と併用できません(手動介入は force:true)");
324
333
  if (mark)
@@ -149,7 +149,7 @@ export class ParentDeliveryManager {
149
149
  this.results = path.join(this.root, "results");
150
150
  this.claims = path.join(options.root ?? path.join(stateRoot, prefix + "parent-deliveries"), "claims");
151
151
  const ownProcess = this.deps.processes([process.pid]).find((entry) => entry.pid === process.pid);
152
- if (!ownProcess)
152
+ if (!ownProcess || ownProcess.started_identity === null)
153
153
  throw new AitermError("PARENT_DELIVERY_OWNER_UNKNOWN: 配送processを識別できません", 2);
154
154
  this.owner = { pid: process.pid, started_identity: ownProcess.started_identity, closed: false, removal_safe: true };
155
155
  this.ownerDir = path.join(this.active, `${deliveryOwnerPrefix(this.owner)}${randomUUID()}`);
@@ -447,11 +447,15 @@ export class ParentDeliveryManager {
447
447
  const processes = this.deps.processes([...new Set(unverified.map((entry) => entry.pid))]);
448
448
  for (const entry of unverified) {
449
449
  const row = processes.find((candidate) => candidate.pid === entry.pid);
450
- const state = !row ? "unverified" : row.started_identity === entry.started_identity ? "alive" : "reused";
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";
451
454
  fate.set(entry.name, state);
452
- // 別のprocessと分かった持ち主は下で閉じるので、照合の結果を覚えない。
453
- if (state !== "reused")
455
+ // 終了と分かった持ち主は下で閉じるので、照合の結果を覚えない。
456
+ if (state === "alive" || state === "unverified") {
454
457
  this.verifiedOwners.set(entry.name, { pid: entry.pid, started_identity: entry.started_identity, at: now, alive: state === "alive" });
458
+ }
455
459
  }
456
460
  }
457
461
  for (const name of this.verifiedOwners.keys()) {
@@ -461,6 +465,8 @@ export class ParentDeliveryManager {
461
465
  }
462
466
  // 記録を引き取り終えた、終了した持ち主の保存場所。残すと、回収のたびに全部を読み直す。
463
467
  const finished = [];
468
+ // pidが無いと分かった、closeしていない持ち主。保存場所を消せない回は、閉じた事だけを書く。
469
+ const goneOwners = [];
464
470
  // owner.jsonの無い、古い保存場所。
465
471
  const abandoned = [];
466
472
  // 旧版は、読んでいる最中に他の持ち主の保存場所が消えると回収が止まる。印の無い持ち主が生きている間は消さない。
@@ -541,23 +547,35 @@ export class ParentDeliveryManager {
541
547
  else
542
548
  this.finish(job);
543
549
  }
544
- if (!owner.closed && state === "reused") {
550
+ if (!owner.closed && (state === "reused" || state === "vanished")) {
545
551
  // 同じpidと開始時刻の組は二度と現れない。閉じた事にして、どの版の回収にも照合を繰り返させない。
546
552
  // 書けなかった時は次の回でもう一度照合する。配送には影響しない。
553
+ const reason = state === "reused" ? "pid_reused" : "process_gone";
547
554
  try {
548
- writeJson0600(ownerFile, { ...owner, closed: true, closed_reason: "pid_reused" });
555
+ writeJson0600(ownerFile, { ...owner, closed: true, closed_reason: reason });
549
556
  }
550
557
  catch { /* 次の回 */ }
551
558
  continue;
552
559
  }
553
560
  // 持ち主が自分で閉じた保存場所と、pidがもう無い持ち主の保存場所だけを消す。
554
- // 開始時刻の違いで閉じた保存場所は、そのpidが空くまで残す。
561
+ // 回収側が閉じた保存場所は、そのpidが空くまで残す。
555
562
  const ended = owner.closed ? owner.closed_reason === undefined || !this.deps.exists(owner.pid) : state === "gone";
556
563
  if (ended)
557
564
  finished.push(oldDir);
565
+ if (!owner.closed && state === "gone")
566
+ goneOwners.push({ file: ownerFile, owner });
558
567
  }
559
- if (removalBlocked)
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
+ }
560
577
  return;
578
+ }
561
579
  // 片付けの失敗は配送に影響しない。残った保存場所は次の回でもう一度消す。
562
580
  for (const directory of finished) {
563
581
  try {
@@ -100,6 +100,7 @@ export function readRuntimeProcesses() {
100
100
  }
101
101
  /**
102
102
  * 指定したpidの開始時刻だけを引く。readRuntimeProcessesと同じ開始時刻を返し、存在しないpidは結果に含めない。
103
+ * process表にpidはあるが開始時刻を読めない時は、started_identityをnullで返す(pidが無いのとは区別する)。
103
104
  * 全processのargvを読む一覧取得は、processの多い端末で重い(定期実行から呼ばない)。
104
105
  */
105
106
  export function readProcessIdentities(pids) {
@@ -124,10 +125,11 @@ export function readProcessIdentities(pids) {
124
125
  });
125
126
  }
126
127
  // CommandLineの読めないprocess(別の権限のserviceなど)も返す。pidが使い回された先を「別のprocess」と確かめるのに要る。
128
+ // 開始時刻を読めない行も、pidがprocess表にある印として返す。
127
129
  const script = [
128
130
  ...WINDOWS_PROBE_HEADER,
129
- `$rows=@(Get-CimInstance Win32_Process -Filter "${wanted.map(pid => `ProcessId=${pid}`).join(" OR ")}" | Where-Object { $null -ne $_.CreationDate } | ForEach-Object {`,
130
- "[ordered]@{ pid=[int]$_.ProcessId; started_identity=$_.CreationDate.ToUniversalTime().ToString('o') }",
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 }) }",
131
133
  "})",
132
134
  "ConvertTo-Json -Compress -InputObject $rows",
133
135
  ].join("\n");
@@ -146,8 +148,11 @@ export function readProcessIdentities(pids) {
146
148
  if (!Array.isArray(rows))
147
149
  throw new AitermError("Windows process一覧が配列ではありません", 2);
148
150
  return rows.map(row => {
149
- if (!row || !Number.isSafeInteger(row.pid) || typeof row.started_identity !== "string"
150
- || !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)))
151
156
  throw new AitermError("Windows process一覧のfieldが不正です", 2);
152
157
  return { pid: row.pid, started_identity: new Date(row.started_identity).toISOString() };
153
158
  });
package/docs/DESIGN.md CHANGED
@@ -47,6 +47,16 @@ psmuxが出力した余分な環境値を返さない。通常PTYとagentへ`AIT
47
47
  sessionの表には環境が丸ごと入るので、名指しで登録した名前をsessionのoption `@aiterm_env_keys`に控え、
48
48
  `env_keys`はその名前だけを返す。psmuxは元から端末ごとに呼び出し元の環境を継ぐ。
49
49
 
50
+ 内部の`killAll`は終了するsocketのsession一覧と、そのsocketのログ等に残るsession名だけを対象にする。
51
+ 別socketが同じ`AITERM_STATE_BASE`を使っていても、他のsessionの登録・完了記録・待機lockを削除しない。
52
+ 対象の一覧取得やserver終了が失敗した場合は登録を削除しない(ADR 0087)。
53
+ 試験の正規入口(`npm test`とCI)は`test/seat-env.mjs`を前処理として読み、席の保存場所・系譜・tmux環境を外し、
54
+ 試験processごとの一時領域を指定する。個別試験が指定する`TMPDIR`と`XDG_RUNTIME_DIR`もそのまま利用できる。
55
+
56
+ agent配送だけを期待する連携元は`pty_send(require_agent:true)`を指定する。agent登録が無ければ、通常PTYへ送る前に
57
+ `AGENT_SESSION_REQUIRED`と「文字列は送信していません」を返す。未送信を確定してから対象sessionを復旧できる。
58
+ 省略時の通常PTY送信は維持し、`force:true`との併用は打鍵前に拒否する。
59
+
50
60
  `pty_observe`は存在、pane/harnessの生存、画面状態と理由、native process identityを分ける。
51
61
  PIDは開始識別子・argv digestと組にし、paneとharnessを同一視しない。特定できないidentityはnull。
52
62
  同じlaunchに属するnpm shimとnative本体は、中間の非候補processも含めた祖先関係から一つの起動として扱う。
@@ -152,7 +162,8 @@ Codexの配送記録と本文はAiterm stateの`parent-deliveries`へ保存す
152
162
  受信口が明示拒否した場合は`failed`、子の異常終了はそのoutcomeを配送する。
153
163
  生存の確認は5秒おきにpidの存在だけをOSへ聞き、開始識別子の照合は初めて見たownerと60秒に1回だけ行う。
154
164
  全processの一覧は取らない。終了直後に同じpidが再利用された時だけ、引き継ぎが最長60秒遅れる(ADR 0078)。
155
- pidが別のprocessへ使い回されたownerは、回収側が`owner.json`を`closed`へ書き換え、照合を繰り返さない。照合できないownerは60秒に1回だけ問い合わせる。
165
+ pidが別のprocessへ使い回されたownerと、pidは居ると出るがprocess表に無いownerは、回収側が`owner.json`を`closed`へ書き換え、照合を繰り返さない。
166
+ process表にはあるが開始時刻を読めないownerだけ、60秒に1回問い合わせる(ADR 0086)。
156
167
  記録を引き継ぎ終えた保存場所は、自分で閉じたownerとpidの無いownerのものを消す。保存場所が消えても読める印(`removal_safe`)の無いownerが
157
168
  同じ置き場で生きている間は消さない(ADR 0084)。
158
169
  このstateは既存のPTY/harness stateと独立し、旧版は配送を再開しない。
@@ -324,6 +335,8 @@ Grokの終了済みerrorターンにweekly-limit質問カードが残る場合
324
335
  `grokRateLimitDialog`が見出しと操作footerの組を所有し、過去logや後続UIのあるカードは採用しない。
325
336
  privacy案内は現在の枠付きcomposerとmodel footerが見える場合だけ入力受付を妨げない。
326
337
  完了観測は成功eventを優先し、今回のerror eventと現在のカードが揃えばturn情報付きの`rate_limited`を返す。
338
+ 完了の見回りは100msで、画面を読む確認(利用上限の知らせ、Cursorのhook拒否の表示)は別の間隔で行う。画面を読むたびにtmuxを1回起動するため。
339
+ Claude・Grokは2秒、Cursorは1秒に1回で、待ちに入った最初の周回では必ず読む(ADR 0085)。
327
340
  新turnの完了前に古いlogだけで上限を返さない。購入・再認証・過去prompt再送・定期再試行は行わない。
328
341
  画面判定と模擬CLIの実PTY試験は`test/grok-rate-limit.test.mjs`に置く。
329
342
 
package/docs/RELEASE.md CHANGED
@@ -17,6 +17,7 @@ Aitermのreleaseはこのrepositoryが所有する。`.github/workflows/product-
17
17
  ## Release手順
18
18
 
19
19
  1. 変更に直結するfocused testを手元で通す。full regressionは手元で回さず、CIに任せる。
20
+ 個別試験も`node --import ./test/seat-env.mjs --test ...`で起動する。稼働中の席の保存場所を試験へ継承しない。
20
21
  2. `CHANGELOG.md`の`## [Unreleased]`へ内容を書き、mainへcommitしてpushする。
21
22
  3. 一回で公開する。
22
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.51.2",
3
+ "version": "0.52.0",
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": [
@@ -64,10 +64,10 @@
64
64
  "mcpb:build": "npm run build && node scripts/build-mcpb.mjs && npm ci --omit=dev --ignore-scripts --no-audit --no-fund --prefix dist/mcpb-stage/server && npx --yes @anthropic-ai/mcpb@2.1.2 validate dist/mcpb-stage/manifest.json && npx --yes @anthropic-ai/mcpb@2.1.2 pack dist/mcpb-stage dist/aiterm-mcp.mcpb",
65
65
  "verify:release-commit": "node scripts/verify-release-commit.mjs",
66
66
  "release": "node scripts/release.mjs",
67
- "test:docs": "node --test test/repository-contract.test.mjs",
67
+ "test:docs": "node --import ./test/seat-env.mjs --test test/repository-contract.test.mjs",
68
68
  "prepublishOnly": "npm run verify:release-commit && npm run build",
69
69
  "start": "node dist/index.js",
70
- "test": "npm run build && node --test scripts/verify-release-commit.test.mjs test/*.test.mjs"
70
+ "test": "npm run build && node --import ./test/seat-env.mjs --test scripts/verify-release-commit.test.mjs test/*.test.mjs"
71
71
  },
72
72
  "dependencies": {
73
73
  "@modelcontextprotocol/sdk": "^1.29.0",