aiterm-mcp 0.61.2 → 0.61.4

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,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.61.4] - 2026-10-22
11
+
12
+ ### 修正
13
+
14
+ - POSIXのnative実行口で最初の`agent_launch`がtmuxソケット未作成時に`EXECUTION_UNSUPPORTED`で止まっていた。発行直前にも空状態を確かめ、最初の`new-session`自身が作ったserverのPID・開始識別子とsession IDを実receiptから照合して起動する。補助sessionや入力再送は作らず、読取不能・receipt不一致は未確認で止める。
15
+
16
+ ## [0.61.3] - 2026-10-22
17
+
18
+ ### Fixed
19
+
20
+ - An unknown send in the opt-in POSIX native execution entry no longer permanently blocks an explicit `pty_close` of that one session. The close is pinned to its expected launch/native identity; only confirmed close and absence release the failed-send admission. The original request remains unknown and is never replayed. Issued work, unknown management operations, and uncertain identities stay blocked.
21
+ - Native handler errors retain their original explanation alongside the typed execution error. A failed recovery close preserves the old launch metadata and reports the native failure.
22
+
10
23
  ## [0.61.2] - 2026-10-22
11
24
 
12
25
  ### 修正
13
26
 
14
- - tmuxがまだ起動しておらず、対象ソケットが存在しない時、新規実行の登録が`EXECUTION_UNCONFIRMED`で止まっていた。実行世代の観測でもソケット未作成を空状態として扱い、初回の席を起動できるようにした。別ソケットや権限・接続の異常は引き続き不明として止める。
27
+ - tmuxがまだ起動しておらず、対象ソケットが存在しない時、新規実行の登録が`EXECUTION_UNCONFIRMED`で止まっていた。実行世代の観測でもソケット未作成を空状態として扱う。別ソケットや権限・接続の異常は引き続き不明として止める。この版では、その次の初回席作成が既存serverを要求しており、冷間起動はまだ完了しなかった。
15
28
 
16
29
  ## [0.61.1] - 2026-10-22
17
30
 
@@ -2715,7 +2728,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
2715
2728
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
2716
2729
  provenance.
2717
2730
 
2718
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.2...HEAD
2731
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.4...HEAD
2732
+ [0.61.4]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.3...v0.61.4
2733
+ [0.61.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.2...v0.61.3
2719
2734
  [0.61.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.1...v0.61.2
2720
2735
  [0.61.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.0...v0.61.1
2721
2736
  [0.61.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.60.1...v0.61.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.61.2** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
212
+ **状態:** 開発継続中 · 現行公開版 **v0.61.4** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -731,11 +731,12 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
731
731
 
732
732
  `pty_send` はコマンドやpromptの意味を判定せず、指定された本文を端末へ送る。既定ではESC・ブラケットペースト終端などをサニタイズし、`pty_read`も制御文字を無害化して返す(`raw: true`はそのまま扱う)。コマンドの許可・拒否はshell、接続先、起動したharnessが所有する。
733
733
 
734
- 1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。全OSで長いPTY入力の欠落を避けるためplatformのmultiplexerへUTF-8境界を壊さない256-byte単位でpasteし、chunk間に10msのdrain間隔を置く。POSIX shellまたはPowerShellが前面にいる時のsanitize済み複数行は、改行を含まない単一入力へ符号化する。POSIXは`eval`、PowerShellはUTF-8のBase64を復元したscriptblockのdot-sourceを使い、変数と作業場所を現在のshellに保つ。shellがscript全体を所有してから先頭行を実行するため、途中で起動したpager/REPLが後続行を対話キーとして奪わない。単一行、`raw:true`、非shell前面は従来どおり直接PTYへpasteする。agent dispatchは本文全体を`ESC[200~/201~`で1回だけ包み、包んだ全体を同じchunkで流す。agent TUIには1回の貼り付けに見えるので画像pathが貼り付けの間で切れず、chunk投入中のキー解釈による語中文字化け・submit取り落としも抑える。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。`pty_list`で対象sessionを確認し、`pty_close`で閉じてから同じsession IDを作り直す。公開の一括停止toolは存在しない。
734
+ 1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。全OSで長いPTY入力の欠落を避けるためplatformのmultiplexerへUTF-8境界を壊さない256-byte単位でpasteし、chunk間に10msのdrain間隔を置く。POSIX shellまたはPowerShellが前面にいる時のsanitize済み複数行は、改行を含まない単一入力へ符号化する。POSIXは`eval`、PowerShellはUTF-8のBase64を復元したscriptblockのdot-sourceを使い、変数と作業場所を現在のshellに保つ。shellがscript全体を所有してから先頭行を実行するため、途中で起動したpager/REPLが後続行を対話キーとして奪わない。単一行、`raw:true`、非shell前面は従来どおり直接PTYへpasteする。agent dispatchは本文全体を`ESC[200~/201~`で1回だけ包み、包んだ全体を同じchunkで流す。agent TUIには1回の貼り付けに見えるので画像pathが貼り付けの間で切れず、chunk投入中のキー解釈による語中文字化け・submit取り落としも抑える。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。`pty_list`で対象sessionを確認し、`pty_close`で閉じてから同じsession IDを作り直す。公開の一括停止toolは存在しない。 POSIXの明示native execution構成では、unknown sendになった対象1席も`pty_close`で閉じて再作成できる。元requestはunknownのまま保持し、再送しない。発行中の操作、管理close自体のunknown、native identityが不明な席は引き続き拒否する。
735
735
 
736
736
  ## 人が覗く
737
737
 
738
738
  セッションは共有tmux socket(Windows nativeはpsmux namespace)上にある。`pty_open`/`agent_launch`の戻り値に表示されるattachコマンドで、人間がClaude/Codex/Grok/Cursor harnessの同じ端末へ入り、途中でキーボードを引き取れる。
739
+ POSIXでは、tmuxのソケットがまだ無くても最初の`agent_launch`でserverを起動できる。Aitermは作成したsessionとserverを実際のreceiptから照合して受付を確定し、不明な結果を入力の再送で補わない。
739
740
 
740
741
  ## 要件
741
742
 
package/README.md CHANGED
@@ -227,7 +227,7 @@ collection is off by default and performs no network I/O. It ships via
227
227
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
228
228
  Release re-registers the Official MCP Registry entry.
229
229
 
230
- **Status:** actively maintained · current public release **v0.61.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).
230
+ **Status:** actively maintained · current public release **v0.61.4** · 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).
231
231
 
232
232
  ### Update and rollback
233
233
 
@@ -789,11 +789,12 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
789
789
 
790
790
  `pty_send` does not interpret command or prompt meaning; it delivers the requested text to the terminal. By default it sanitizes ESC and bracketed-paste terminators, while `pty_read` neutralizes control characters in returned output (`raw: true` keeps them unchanged). The shell, remote endpoint, or launched harness owns command authorization.
791
791
 
792
- Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. Every OS pastes through its multiplexer in UTF-8-safe 256-byte chunks with a 10 ms drain interval; macOS, Linux, and WSL2 have all demonstrated silent middle/trailing loss when a long input is pushed without that boundary. Sanitized multiline text sent while a POSIX shell or PowerShell is in the foreground is encoded as one newline-free input (`eval` for POSIX shells; a dot-sourced, UTF-8 Base64-decoded scriptblock for PowerShell): the shell receives the complete script before it runs the first line, so a pager or REPL started mid-script cannot consume later lines as interactive keystrokes. Single-line input, `raw:true`, and non-shell frontends remain direct PTY pastes. Agent dispatches additionally wrap the whole text once in `ESC[200~/201~` and stream the wrapped text in the same chunks, so the agent TUI sees one paste (an image path is never split across pastes), hardening prompt injection against mid-word key-interpretation corruption and dropped submits. If a later chunk fails, aiterm reports the partial-send state and does not press Enter automatically. A lock left by a terminated sender fails closed before sending; use `pty_list` to confirm the affected session, close it with `pty_close`, then recreate the same session ID. There is no public kill-all tool.
792
+ Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. Every OS pastes through its multiplexer in UTF-8-safe 256-byte chunks with a 10 ms drain interval; macOS, Linux, and WSL2 have all demonstrated silent middle/trailing loss when a long input is pushed without that boundary. Sanitized multiline text sent while a POSIX shell or PowerShell is in the foreground is encoded as one newline-free input (`eval` for POSIX shells; a dot-sourced, UTF-8 Base64-decoded scriptblock for PowerShell): the shell receives the complete script before it runs the first line, so a pager or REPL started mid-script cannot consume later lines as interactive keystrokes. Single-line input, `raw:true`, and non-shell frontends remain direct PTY pastes. Agent dispatches additionally wrap the whole text once in `ESC[200~/201~` and stream the wrapped text in the same chunks, so the agent TUI sees one paste (an image path is never split across pastes), hardening prompt injection against mid-word key-interpretation corruption and dropped submits. If a later chunk fails, aiterm reports the partial-send state and does not press Enter automatically. A lock left by a terminated sender fails closed before sending; use `pty_list` to confirm the affected session, close it with `pty_close`, then recreate the same session ID. There is no public kill-all tool. In the opt-in POSIX native execution entry, an unknown send can also be recovered by explicitly closing that one session with `pty_close`, then recreating it. The original request remains unknown and must not be replayed. Issued work, unknown management closes, and uncertain native identity remain blocked.
793
793
 
794
794
  ## A human can watch
795
795
 
796
796
  Sessions live on a shared tmux socket on POSIX or a shared psmux namespace on native Windows. The attach line printed by `pty_open` and `agent_launch` lets a human attach to the same terminal and intervene, including a Claude/Codex/Grok/Cursor harness session: `tmux -S … attach -t <id>` on POSIX, or `psmux -L <namespace> attach -t <id>` on native Windows.
797
+ On POSIX, the first `agent_launch` can create the tmux server when its socket does not yet exist. Aiterm verifies the exact created session and server before accepting that launch; an uncertain result is reported without retrying the input.
797
798
 
798
799
  ## Requirements
799
800
 
package/dist/core.js CHANGED
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
16
16
  import * as rtk from "./rtk.js";
17
17
  import { closeExpectedExecution, parseExecutionNativeSessions, executionNativeBindingSchema } from "./execution-close.js";
18
18
  import { executionPointSchema } from "./execution-contract.js";
19
- import { withExecutionInterruptEffectScope, bindExecutionNativeTarget, beforeExecutionEffect, bindExecutionCreatedLaunch, executionCreatedNativeBinding, hasExecutionCreationScope, assertExecutionContext, withExecutionPreparation, captureExecutionSendBoundary, readExecutionSendBoundary } from "./execution-effect.js";
19
+ import { withExecutionInterruptEffectScope, bindExecutionNativeTarget, beforeExecutionEffect, bindExecutionCreatedLaunch, executionCreatedNativeBinding, hasExecutionCreationScope, assertExecutionContext, withExecutionPreparation, captureExecutionSendBoundary, readExecutionSendBoundary, executionManagementCloseTarget } from "./execution-effect.js";
20
20
  import { ExecutionError } from "./execution-wire.js";
21
21
  import { readExecutionParentProcess } from "./execution-parent-process.js";
22
22
  import { isCursorMcpClient } from "aiterm-steer-delivery";
@@ -1869,7 +1869,12 @@ function closeSessionInternal(name, observeDependency = true, o = {}) {
1869
1869
  assertSessionClosable(name);
1870
1870
  if (o.letHarnessExit)
1871
1871
  letClaudeExitBeforeClose(name);
1872
- (observeDependency ? tmux : tmuxCleanup)("kill-session", "-t", name);
1872
+ const killed = (observeDependency ? tmux : tmuxCleanup)("kill-session", "-t", name);
1873
+ if (executionManagementCloseTarget(name) && killed.code !== 0) {
1874
+ // Recovery must not erase the old launch metadata or report closed when
1875
+ // its one pinned native close failed. Keep the original send uncertainty.
1876
+ throw new AitermError(`tmux sessionを閉じられませんでした: ${killed.stderr.trim() || `code=${killed.code}`}`, 2);
1877
+ }
1873
1878
  for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name), sendLockPath(name)]) {
1874
1879
  try {
1875
1880
  fs.unlinkSync(p);
@@ -140,12 +140,15 @@ export function createExecutionDispatcher(bindings) {
140
140
  throw new ExecutionError("EXECUTION_UNCONFIRMED");
141
141
  const context = { schema: "aiterm.execution-source.v1", source };
142
142
  const supplied = executionRefFromMeta(fixedExtra._meta);
143
+ // A host's explicit session close is a management RPC with its own
144
+ // existing process/request identity, not a delivery or resend.
145
+ const hostClose = client === "bellteam" && route.tool === "pty_close" && !supplied;
143
146
  if (args.remote !== undefined && args.remote !== null)
144
147
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
145
148
  let reference = supplied;
146
149
  let directRequest = null;
147
150
  let frozenParent = null;
148
- if (!reference && client !== "bellteam" && native) {
151
+ if (!reference && (client !== "bellteam" || hostClose) && native) {
149
152
  directRequest = executionRequestSchema.options[4].parse({ kind: "mcp", process_pid: native.pid,
150
153
  process_started_identity: native.started_identity, request_id: fixedExtra.requestId,
151
154
  tool: route.tool, target_session_id: route.target });
@@ -186,7 +189,7 @@ export function createExecutionDispatcher(bindings) {
186
189
  }
187
190
  else {
188
191
  // BellTeam must retain its existing delivery/room/call reference.
189
- if (client === "bellteam" || !directRequest)
192
+ if ((client === "bellteam" && !hostClose) || !directRequest)
190
193
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
191
194
  const current = await observe(route.target);
192
195
  if (route.stage === "send" && !current.point)
@@ -204,15 +207,21 @@ export function createExecutionDispatcher(bindings) {
204
207
  const expected = status.relation.target?.launch ?? (status.reservation.target.expected_launch_id
205
208
  ? { session_id: route.target, launch_id: status.reservation.target.expected_launch_id } : null);
206
209
  const requestMeta = { ...fixedExtra._meta, [EXECUTION_REQUEST_META_KEY]: reference };
207
- return await runDeferredBoundExecution(bindings.service, requestMeta, route.stage, route.target, async () => {
210
+ let handlerFailure;
211
+ const bound = await runDeferredBoundExecution(bindings.service, requestMeta, route.stage, route.target, async () => {
208
212
  const result = await withExecutionRequest(reference.request, () => body(args, { ...fixedExtra, _meta: requestMeta }), {
209
213
  source: status.source.status === "verified" ? status.source.point : null,
210
214
  client_name: client ?? null, native_identity: native,
211
215
  metadata: fixedExtra._meta === undefined ? null : fixedExtra._meta,
212
216
  ...(captureNativeParent ? { native_parent: frozenParent } : {}),
213
217
  });
214
- if (result.isError)
218
+ if (result.isError) {
219
+ // Keep the original native/handler refusal for the caller. It is
220
+ // still an error, never an accepted receipt or replay permission;
221
+ // the service determines whether an effect crossed its gate.
222
+ handlerFailure = result;
215
223
  throw new ExecutionError("EXECUTION_UNCONFIRMED");
224
+ }
216
225
  // Missing/invalid binding evidence is returned as null. The wrapper
217
226
  // retains the genuine successful receipt if binding then fails; it
218
227
  // must not turn a valid accepted action into permission to resend.
@@ -263,6 +272,8 @@ export function createExecutionDispatcher(bindings) {
263
272
  }
264
273
  return { result, target };
265
274
  }, cancellation?.assert);
275
+ return handlerFailure && bound.isError ? { ...handlerFailure,
276
+ _meta: { ...handlerFailure._meta, ...bound._meta } } : bound;
266
277
  }
267
278
  catch (error) {
268
279
  return executionErrorResult(error);
@@ -276,7 +287,7 @@ export function createExecutionDispatcher(bindings) {
276
287
  const failure = executionErrorResult(new ExecutionError("EXECUTION_UNCONFIRMED", "possible"));
277
288
  // Preserve a genuine accepted result even when cancellation recording
278
289
  // failed. Status-only uncertainty must never become permission to resend.
279
- return result.isError ? failure : { ...result, _meta: { ...result._meta,
290
+ return { ...result, _meta: { ...result._meta,
280
291
  [EXECUTION_ERROR_META_KEY]: failure._meta[EXECUTION_ERROR_META_KEY] } };
281
292
  }
282
293
  return result;
@@ -197,10 +197,13 @@ export function prepareExecutionCreation() {
197
197
  refuse(scope, "EXECUTION_UNSUPPORTED");
198
198
  try {
199
199
  const server = scope.creation.prepare();
200
- if (!Number.isSafeInteger(server?.pid) || server.pid < 1 || typeof server.started_identity !== "string" || !server.started_identity)
200
+ if (server !== null && (!Number.isSafeInteger(server?.pid) || server.pid < 1
201
+ || typeof server.started_identity !== "string" || !server.started_identity))
201
202
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
202
- scope.creationServer = { ...server };
203
+ if (server)
204
+ scope.creationServer = { ...server };
203
205
  scope.creationPrepared = true;
206
+ return server === null;
204
207
  }
205
208
  catch (error) {
206
209
  scope.failure = new ExecutionError(error instanceof ExecutionError ? error.code : "EXECUTION_UNCONFIRMED", "not_started");
@@ -208,7 +211,7 @@ export function prepareExecutionCreation() {
208
211
  }
209
212
  }
210
213
  /** Private core metadata stamp, derived only from this frame's actual create
211
- * receipt and pre-existing native server proof. Not a tool input or lookup.
214
+ * receipt and verified native server proof. Not a tool input or name lookup.
212
215
  */
213
216
  export function executionCreatedNativeBinding(session) {
214
217
  const scope = scopes.getStore();
@@ -237,7 +240,14 @@ export function captureExecutionCreatedNative(receipt) {
237
240
  if (!parsed || parsed[2] !== scope.session || (scope.createdNative && scope.createdNative !== parsed[1]))
238
241
  refuse(scope, "EXECUTION_CONFLICT");
239
242
  try {
240
- scope.creation.native(scope.session, parsed[1], Number(parsed[3]));
243
+ const server = scope.creation.native(scope.session, parsed[1], Number(parsed[3]));
244
+ if (server) {
245
+ if (!Number.isSafeInteger(server.pid) || server.pid < 1 || typeof server.started_identity !== "string" || !server.started_identity)
246
+ throw new ExecutionError("EXECUTION_UNCONFIRMED", "possible");
247
+ scope.creationServer = { ...server };
248
+ }
249
+ if (!scope.creationServer)
250
+ throw new ExecutionError("EXECUTION_UNCONFIRMED", "possible");
241
251
  }
242
252
  catch (error) {
243
253
  scope.failure = new ExecutionError(error instanceof ExecutionError ? error.code : "EXECUTION_UNCONFIRMED", "possible");
@@ -286,6 +296,21 @@ export function bindExecutionNativeTarget(session, nativeId) {
286
296
  refuse(scope, "EXECUTION_CONFLICT");
287
297
  scope.nativeTargets.add(nativeId);
288
298
  }
299
+ /** Bound only by the management-close wrapper, never tool arguments. The
300
+ * ordinary core close/cleanup keeps using its session name; native commands
301
+ * are pinned to the independently captured native ID through the same gate.
302
+ */
303
+ export function bindExecutionManagementCloseTarget(session, nativeId) {
304
+ const scope = scopes.getStore();
305
+ if (!scope || scope.session !== session || scope.didIssue || scope.creation || scope.interrupt || !/^\$\d+$/.test(nativeId))
306
+ throw new ExecutionError("EXECUTION_UNSUPPORTED");
307
+ scope.managementCloseTarget = nativeId;
308
+ scope.nativeTargets.add(nativeId);
309
+ }
310
+ export function executionManagementCloseTarget(session) {
311
+ const scope = scopes.getStore();
312
+ return scope?.session === session ? scope.managementCloseTarget : undefined;
313
+ }
289
314
  export function beforeExecutionEffect(session, verify) {
290
315
  const scope = scopes.getStore();
291
316
  if (!scope) {
@@ -376,7 +401,7 @@ export function guardTmuxExecution(input) {
376
401
  if (!parsed || parsed[1] !== scope.session)
377
402
  refuse(scope, "EXECUTION_CONFLICT");
378
403
  if (command !== "new-session")
379
- args[at + 1] = `${scope.createdNative ?? "=" + scope.session}${parsed[2] ?? ":"}`;
404
+ args[at + 1] = `${scope.managementCloseTarget ?? scope.createdNative ?? "=" + scope.session}${parsed[2] ?? ":"}`;
380
405
  }
381
406
  effects = true;
382
407
  }
@@ -84,11 +84,13 @@ export function createExecutionRuntime(bindings) {
84
84
  session_id: executionLaunchSchema.shape.session_id, native_id: z.string().regex(/^\$\d+$/),
85
85
  }).strict()) }).strict().parse(bindings.observeNativeSessionsSync());
86
86
  if (new Set(parsed.sessions.map(value => value.session_id)).size !== parsed.sessions.length
87
- || new Set(parsed.sessions.map(value => value.native_id)).size !== parsed.sessions.length)
87
+ || new Set(parsed.sessions.map(value => value.native_id)).size !== parsed.sessions.length
88
+ || (parsed.server === null && parsed.sessions.length > 0))
88
89
  return { state: "unknown" };
90
+ const server = parsed.server === null ? { server: null } : parsed.server ? { server: parsed.server } : {};
89
91
  const target = parsed.sessions.find(value => value.session_id === session);
90
- return target ? { state: "present", native_id: target.native_id, ...(parsed.server ? { server: parsed.server } : {}) }
91
- : { state: "absent", ...(parsed.server ? { server: parsed.server } : {}) };
92
+ return target ? { state: "present", native_id: target.native_id, ...server }
93
+ : { state: "absent", ...server };
92
94
  }
93
95
  catch {
94
96
  return { state: "unknown" };
@@ -41,6 +41,30 @@ function find(state, request) {
41
41
  throw new ExecutionError("EXECUTION_NOT_FOUND");
42
42
  return record;
43
43
  }
44
+ const managementClose = (request) => request.kind === "mcp" && request.tool === "pty_close";
45
+ /** Only a returned, unknown send may be displaced by an explicit close of
46
+ * that exact launch. Issued work, management/parent slots and other generations
47
+ * remain blockers. The record is never turned into accepted or not_sent.
48
+ */
49
+ function recoverableSends(state, request, session, expected) {
50
+ if (!managementClose(request) || expected === null)
51
+ return [];
52
+ return state.slots.filter(slot => {
53
+ if (slot.session_id !== session || slot.expected_launch_id !== expected || slot.stage !== "send"
54
+ || slot.state !== "unknown" || !slot.request || same(slot.request, request))
55
+ return false;
56
+ if (slot.request.kind === "mcp" && !["pty_send", "claude_turn.issue"].includes(slot.request.tool))
57
+ return false;
58
+ const original = state.records.find(record => same(record.reservation.request, slot.request));
59
+ return original?.operations.send === "unknown" && !["issued", "unknown"].includes(original.operations.launch)
60
+ && original.relation.phase === "submitted_unknown" && original.relation.completion === "unknown"
61
+ && same(original.relation.target?.launch, { session_id: session, launch_id: expected });
62
+ });
63
+ }
64
+ function targetBlocked(state, request, session, expected) {
65
+ const recoverable = recoverableSends(state, request, session, expected);
66
+ return state.slots.some(slot => slot.session_id === session && !recoverable.includes(slot));
67
+ }
44
68
  function view(state, record) {
45
69
  return executionRecordSchema.parse({ ...record,
46
70
  stopping: record.stopping || stopped(state, record.relation.source?.launch) || stopped(state, record.relation.target?.launch)
@@ -384,18 +408,40 @@ export class ExecutionService {
384
408
  if (record.operations.launch !== "preparing" || record.relation.lifecycle === "retired")
385
409
  throw new ExecutionError("EXECUTION_CONFLICT");
386
410
  const native = this.runtime.nativeSessionSync(record.reservation.target.session_id);
387
- if (native.state !== "absent" || !native.server)
411
+ if (native.state !== "absent" || native.server === undefined)
388
412
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
389
413
  return native.server;
390
414
  }, { readOnly: true });
391
415
  }
416
+ /** Private wrapper preparation for an explicit management close. No native
417
+ * effect or slot removal occurs here. The invoke gate independently rechecks
418
+ * this same identity immediately before every native command.
419
+ */
420
+ managementCloseNative(metaInput) {
421
+ const { request } = executionRefMetaSchema.parse(metaInput);
422
+ if (!managementClose(request))
423
+ return undefined;
424
+ if (!this.store.transactionSync)
425
+ throw new ExecutionError("EXECUTION_UNSUPPORTED");
426
+ return this.store.transactionSync(state => {
427
+ const record = find(state, request), target = record.reservation.target;
428
+ if (!recoverableSends(state, request, target.session_id, target.expected_launch_id).length)
429
+ return undefined;
430
+ if (record.operations.send !== "preparing" || targetBlocked(state, request, target.session_id, target.expected_launch_id))
431
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
432
+ const native = this.runtime.nativeSessionSync?.(target.session_id);
433
+ if (native?.state !== "present" || !native.server)
434
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
435
+ return native.native_id;
436
+ }, { readOnly: true });
437
+ }
392
438
  verifyCreationServer(metaInput, expected) {
393
439
  const { request } = executionRefMetaSchema.parse(metaInput);
394
440
  if (!this.store.transactionSync)
395
441
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
396
442
  this.store.transactionSync(state => {
397
443
  const record = find(state, request), native = this.runtime.nativeSessionSync?.(record.reservation.target.session_id);
398
- if (!native?.server || !same(native.server, expected))
444
+ if (native?.state !== "absent" || !same(native.server, expected))
399
445
  throw new ExecutionError("EXECUTION_UNCONFIRMED");
400
446
  }, { readOnly: true });
401
447
  }
@@ -403,18 +449,23 @@ export class ExecutionService {
403
449
  const { request } = executionRefMetaSchema.parse(metaInput);
404
450
  if (!/^\$\d+$/.test(nativeId) || !this.store.transactionSync)
405
451
  throw new ExecutionError("EXECUTION_UNSUPPORTED", "possible");
406
- this.store.transactionSync(state => {
452
+ return this.store.transactionSync(state => {
407
453
  const record = find(state, request), slot = state.slots.find(value => same(value.request, request) && value.stage === "launch");
454
+ // A cold first new-session creates the server itself. Prove its owner
455
+ // from the exact receipt and a stable native listing before stamping it.
456
+ const observed = expectedServer ? null : this.runtime.nativeSessionSync?.(session);
457
+ const server = expectedServer ?? (observed?.state === "present" && observed.native_id === nativeId ? observed.server : undefined);
408
458
  if (!slot || slot.state !== "issued" || record.operations.launch !== "issued" || slot.session_id !== session
409
459
  || record.reservation.target.expected_launch_id !== null || record.relation.lifecycle === "retired"
410
- || expectedServer.pid !== receiptPid
460
+ || !server || server.pid !== receiptPid
411
461
  || (slot.created_native_id && slot.created_native_id !== nativeId)
412
- || (slot.created_server && !same(slot.created_server, expectedServer)))
462
+ || (slot.created_server && !same(slot.created_server, server)))
413
463
  throw new ExecutionError("EXECUTION_CONFLICT", "possible");
414
464
  // Preserve this genuine native receipt even if a stop is already fencing
415
465
  // its caller. Binding metadata does not authorize another native action.
416
466
  slot.created_native_id = nativeId;
417
- slot.created_server = expectedServer;
467
+ slot.created_server = server;
468
+ return server;
418
469
  });
419
470
  }
420
471
  bindCreatedLaunch(metaInput, input) {
@@ -564,7 +615,7 @@ export class ExecutionService {
564
615
  }
565
616
  sourceAdmission(state, source.point, args.source_session_id);
566
617
  const target = args.reservation.target;
567
- if (state.slots.some(slot => slot.session_id === target.session_id))
618
+ if (targetBlocked(state, args.reservation.request, target.session_id, target.expected_launch_id))
568
619
  throw new ExecutionError("EXECUTION_UNCONFIRMED", "possible");
569
620
  const current = await this.runtime.observe(target.session_id);
570
621
  this.expectTarget(current, target.expected_launch_id, target.session_id);
@@ -606,6 +657,7 @@ export class ExecutionService {
606
657
  }
607
658
  async invokeInternal(metaInput, stage, body, targetSessionId, deferred, admission) {
608
659
  const { request } = executionRefMetaSchema.parse(metaInput);
660
+ let expectedCloseNative;
609
661
  if (deferred && (!this.store.transactionSync || !this.runtime.observeSync))
610
662
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
611
663
  await this.store.transaction(async (state) => {
@@ -625,6 +677,16 @@ export class ExecutionService {
625
677
  if (record.reservation.source.launch && !relation.source)
626
678
  throw new ExecutionError("EXECUTION_UNCONFIRMED");
627
679
  if (deferred) {
680
+ const target = record.reservation.target;
681
+ if (recoverableSends(state, request, target.session_id, target.expected_launch_id).length) {
682
+ if (targetBlocked(state, request, target.session_id, target.expected_launch_id))
683
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
684
+ this.expectTarget(await this.runtime.observe(target.session_id), target.expected_launch_id, target.session_id);
685
+ const native = this.runtime.nativeSessionSync?.(target.session_id);
686
+ if (native?.state !== "present" || !native.server)
687
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
688
+ expectedCloseNative = { native_id: native.native_id, server: structuredClone(native.server) };
689
+ }
628
690
  record.operations[stage] = "preparing";
629
691
  return; // no native effect or target slot yet; stop can cancel this
630
692
  }
@@ -654,6 +716,14 @@ export class ExecutionService {
654
716
  state.slots.push({ session_id: target.session_id, expected_launch_id: expected, stage, state: "issued", request });
655
717
  });
656
718
  let issued = !deferred, finished = false;
719
+ const checkCloseNative = (session) => {
720
+ if (!expectedCloseNative)
721
+ return;
722
+ const native = this.runtime.nativeSessionSync?.(session);
723
+ if (native?.state !== "present" || native.native_id !== expectedCloseNative.native_id
724
+ || !same(native.server, expectedCloseNative.server))
725
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
726
+ };
657
727
  const checkOrIssue = (commit, preparation) => {
658
728
  admission?.();
659
729
  const prepared = () => { admission?.(); return preparation(); };
@@ -688,6 +758,7 @@ export class ExecutionService {
688
758
  throw new ExecutionError("EXECUTION_STALE_GENERATION");
689
759
  }
690
760
  const current = this.runtime.observeSync(slot.session_id);
761
+ checkCloseNative(slot.session_id);
691
762
  if (current.state === "present" && stopped(state, current.launch))
692
763
  throw new ExecutionError("EXECUTION_STOPPING");
693
764
  if (slot.created_native_id) {
@@ -725,11 +796,12 @@ export class ExecutionService {
725
796
  throw new ExecutionError("EXECUTION_STALE_GENERATION");
726
797
  }
727
798
  const target = record.reservation.target;
728
- if (state.slots.some(slot => slot.session_id === target.session_id))
799
+ if (targetBlocked(state, request, target.session_id, target.expected_launch_id))
729
800
  throw new ExecutionError("EXECUTION_UNCONFIRMED");
730
801
  const expected = relation.target?.launch.launch_id ?? target.expected_launch_id;
731
802
  const current = this.runtime.observeSync(target.session_id);
732
803
  this.expectTarget(current, expected, target.session_id);
804
+ checkCloseNative(target.session_id);
733
805
  if (current.state === "present" && stopped(state, current.launch))
734
806
  throw new ExecutionError("EXECUTION_STOPPING");
735
807
  if ((stage === "launch" && current.state !== "absent") || (stage === "send" && current.state !== "present"))
@@ -830,8 +902,14 @@ export class ExecutionService {
830
902
  if (state.stopping.some(value => same(value, record.relation.source?.launch)) && !state.stopping.some(value => same(value, targetPoint.launch)))
831
903
  state.stopping.push(targetPoint.launch);
832
904
  state.slots = state.slots.filter(value => !same(value.request, request));
833
- if (closing)
905
+ if (closing) {
906
+ // Actual management close plus confirmed absence releases only these
907
+ // failed send admissions. Old callbacks still fail their missing-slot
908
+ // guard, and their records remain unknown and cannot be invoked again.
909
+ const recovered = recoverableSends(state, request, slot.session_id, expected);
910
+ state.slots = state.slots.filter(value => !recovered.includes(value));
834
911
  this.interruptRelations(state, targetPoint.launch);
912
+ }
835
913
  return view(state, record);
836
914
  }).catch(async () => {
837
915
  // The side effect already returned, but its result could not be safely
@@ -1326,6 +1404,8 @@ export class ExecutionService {
1326
1404
  interruptRelations(state, launch) {
1327
1405
  for (const record of state.records)
1328
1406
  if (same(record.relation.target?.launch, launch)) {
1407
+ if (record.operations.launch === "unknown" || record.operations.send === "unknown")
1408
+ continue;
1329
1409
  record.relation.completion = "interrupted";
1330
1410
  record.relation.target_fence = "closed";
1331
1411
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { executionReservationSchema, executionRequestSchema, executionPointSchema, cascadeInterruptRequestSchema, cascadeInterruptResultObjectSchema } from "./execution-contract.js";
3
3
  import { executionActionSchema, executionFinishSchema, executionRecordSchema, executionBindingSchema, executionRootsRequestSchema, executionRootsResultObjectSchema, executionRefFromMeta, executionErrorResult, ExecutionError, EXECUTION_BINDING_META_KEY, EXECUTION_ERROR_META_KEY, EXECUTION_SOURCE_META_KEY } from "./execution-wire.js";
4
- import { withExecutionEffectScope } from "./execution-effect.js";
4
+ import { withExecutionEffectScope, bindExecutionManagementCloseTarget } from "./execution-effect.js";
5
5
  import { immediateStopInputSchema, immediateStopResultSchema } from "./execution-immediate-stop.js";
6
6
  export function executionBindingMeta(record) {
7
7
  return { [EXECUTION_BINDING_META_KEY]: executionBindingSchema.parse({
@@ -119,14 +119,19 @@ async function runExecutionReceipt(service, meta, stage, body, targetSessionId,
119
119
  const result = deferred
120
120
  ? await service.invokeDeferred(ref, stage, async (issue, prepare) => {
121
121
  let expectedServer;
122
- const action = await withExecutionEffectScope(targetSessionId, issue, run, stage === "launch" ? {
122
+ const action = await withExecutionEffectScope(targetSessionId, issue, () => {
123
+ const native = service.managementCloseNative(ref);
124
+ if (native)
125
+ bindExecutionManagementCloseTarget(targetSessionId, native);
126
+ return run();
127
+ }, stage === "launch" ? {
123
128
  prepare: () => { expectedServer = service.prepareCreation(ref); return expectedServer; },
124
- verifyServer: () => { if (!expectedServer)
129
+ verifyServer: () => { if (expectedServer === undefined)
125
130
  throw new ExecutionError("EXECUTION_UNSUPPORTED"); service.verifyCreationServer(ref, expectedServer); },
126
131
  native: (session, nativeId, serverPid) => {
127
- if (!expectedServer)
132
+ if (expectedServer === undefined)
128
133
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
129
- service.captureCreatedNative(ref, session, nativeId, expectedServer, serverPid);
134
+ return service.captureCreatedNative(ref, session, nativeId, expectedServer, serverPid);
130
135
  },
131
136
  launch: launch => service.bindCreatedLaunch(ref, launch),
132
137
  } : undefined, prepare);
@@ -493,12 +493,12 @@ export function registeredSessionEnvironment(name) {
493
493
  export function tmuxNewSession(observe, name, launch, registered = []) {
494
494
  const guarded = hasExecutionCreationScope();
495
495
  // Windows per-session server creation needs a separate native-owner proof.
496
- // The legacy public launcher is unchanged; guarded creation cannot assume
497
- // POSIX's existing shared-server identity or bootstrap a missing server.
496
+ // The legacy public launcher is unchanged.
497
+ let cold = false;
498
498
  if (guarded) {
499
499
  if (isWin)
500
500
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
501
- prepareExecutionCreation();
501
+ cold = prepareExecutionCreation();
502
502
  }
503
503
  const printNative = guarded ? ["-P", "-F", "#{session_id}\t#{session_name}\t#{pid}"] : [];
504
504
  if (isWin) {
@@ -508,6 +508,15 @@ export function tmuxNewSession(observe, name, launch, registered = []) {
508
508
  return result;
509
509
  }
510
510
  const env = sessionCallerEnvironment();
511
+ if (cold) {
512
+ // With no server, there is no global environment to adjust. The first
513
+ // new-session inherits this caller's environment and prints its native ID
514
+ // and newly created server PID for the guarded receipt proof.
515
+ const r = runTmux(observe, undefined, ["new-session", "-d", "-s", name, ...printNative, ...launch.args, launch.shell], env);
516
+ if (r.code === 0 || r.stdout)
517
+ captureExecutionCreatedNative(r.stdout);
518
+ return r;
519
+ }
511
520
  const wildcard = tmuxHasSessionEnvironmentFlags();
512
521
  // tmux は起動時に PWD を共通環境へ足す。
513
522
  const others = new Set(["PWD"]);
package/docs/DESIGN.md CHANGED
@@ -28,8 +28,10 @@ native emit直前に照合したhook参照は既存deliveryへ保存する。Cod
28
28
  POSIXのraw tty回復もnative作用であり、tmux打鍵と同じ受付を通す。ttyは正確なsession selectorで読み、
29
29
  実stty直前にも同じdeviceを確認する。停止や世代/tty交代後に古いdeviceへ回復を発行しない。
30
30
  一つのRPCが複数のnative作用を発行するときも、各回に同じissued slotとsource/target世代・停止を再確認する。
31
- 新launchは、既存POSIX serverのnative owner、new-session自身のnative ID receipt、core内部で生成したlaunch世代を
32
- 同じ要求slotへ段階結合する。物理shell準備はnative IDへ固定し、未結合の同名世代を採用しない。
31
+ 新launchは、既存POSIX serverのnative owner、またはserver未作成の完全な空観測を受付時と発行直前に確認する。
32
+ 冷間時は最初のnew-session自身でserverを作り、その実receiptのnative IDとserver PIDを、作成後の安定したnative一覧とprocess開始識別子で照合する。
33
+ 既存server時も冷間時も、作成したsessionのnative ID・server owner・core内部のlaunch世代を同じ要求slotへ段階結合する。
34
+ 物理shell準備はnative IDへ固定し、未結合の同名世代を採用しない。冷間起動のための補助sessionや再送は作らない。
33
35
  作成時のnative ID/server ownerをmanaged metadataへ保存し、private runtimeの世代読取と期待closeでも再照合する。
34
36
  古いmetadataの欠測を現観測で補わず、既存席の自動登録は行わない。
35
37
  証拠の無い新server起動、Windows固有のserver作成、auto-name/通常PTY/auth等はまだunsupported。
@@ -424,6 +426,15 @@ ADR 0074(repositoryの`docs/adr/0074-agent-model-catalog.md`)。
424
426
  境界失敗は明示errorにする。retry、別model、別harness、別backendへ自動fallbackしない。
425
427
  stale send lockは並行processとのABAを避けるため自動削除せず、公開APIでは対象sessionを`pty_close`して
426
428
  同じIDで再作成する。全session一括停止は公開しない。
429
+ POSIXの明示native execution構成では、返却済みsendのunknown枠が対象席の管理closeまで
430
+ 永久に拒否しないよう、通常`pty_close`だけを同じ期待launch/native IDで受付できる。
431
+ unknown send枠を保持したまま既存core closeへ進み、native close成功と席の不在を確認した後だけ
432
+ 該当send枠を解放する。元recordのphase/completion/sendはunknownのまま残し、再送や成功化をしない。
433
+ 旧callbackは未知/消失した元slotで拒否され、同IDの新launchへnative作用を発行できない。
434
+ issued、管理close自体のunknown、親作用のslot、世代/native/serverの不明・交代は対象外。
435
+ 既存の送信・待機lockの拒否も維持し、他席やissuerの停止を復旧条件へ追加しない。
436
+ native handlerのisErrorは元の本文を保持し、`aiterm/execution_error`のcode/effectを併記する。
437
+ native作用後の不明はpossibleのままで、原因説明を再送可否や成功receiptへ変換しない。
427
438
  agent sessionへの送信1本を守る`<name>.agent-send.lock`は持つ時間が長いので、持ち主が終了した残骸を次の送信が片付ける。消す役を`<name>.agent-send.lock.reap`の排他作成で1つに絞り、
428
439
  その中でもう一度確かめてから消すので、確かめた後で別のlockに入れ替わらない。消す役の印そのものが残った時だけは自動で消さず、`pty_close`を案内する。
429
440
  終了した持ち主のpidを別のprocessが使い回すと、pidだけでは生きて見える。生きて見える持ち主は、processが始まってからの経過とlockを作ってからの時間を比べ、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.61.2",
3
+ "version": "0.61.4",
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": [