aiterm-mcp 0.61.2 → 0.61.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,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.61.3] - 2026-10-22
11
+
12
+ ### Fixed
13
+
14
+ - 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.
15
+ - 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.
16
+
10
17
  ## [0.61.2] - 2026-10-22
11
18
 
12
19
  ### 修正
@@ -2715,7 +2722,8 @@ prototype (preserved under `prototype/python/` as the porting source and referen
2715
2722
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
2716
2723
  provenance.
2717
2724
 
2718
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.2...HEAD
2725
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.3...HEAD
2726
+ [0.61.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.2...v0.61.3
2719
2727
  [0.61.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.1...v0.61.2
2720
2728
  [0.61.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.61.0...v0.61.1
2721
2729
  [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.3** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
213
213
 
214
214
  ### 更新と巻き戻し
215
215
 
@@ -731,7 +731,7 @@ 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
 
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.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).
231
231
 
232
232
  ### Update and rollback
233
233
 
@@ -789,7 +789,7 @@ 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
 
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;
@@ -286,6 +286,21 @@ export function bindExecutionNativeTarget(session, nativeId) {
286
286
  refuse(scope, "EXECUTION_CONFLICT");
287
287
  scope.nativeTargets.add(nativeId);
288
288
  }
289
+ /** Bound only by the management-close wrapper, never tool arguments. The
290
+ * ordinary core close/cleanup keeps using its session name; native commands
291
+ * are pinned to the independently captured native ID through the same gate.
292
+ */
293
+ export function bindExecutionManagementCloseTarget(session, nativeId) {
294
+ const scope = scopes.getStore();
295
+ if (!scope || scope.session !== session || scope.didIssue || scope.creation || scope.interrupt || !/^\$\d+$/.test(nativeId))
296
+ throw new ExecutionError("EXECUTION_UNSUPPORTED");
297
+ scope.managementCloseTarget = nativeId;
298
+ scope.nativeTargets.add(nativeId);
299
+ }
300
+ export function executionManagementCloseTarget(session) {
301
+ const scope = scopes.getStore();
302
+ return scope?.session === session ? scope.managementCloseTarget : undefined;
303
+ }
289
304
  export function beforeExecutionEffect(session, verify) {
290
305
  const scope = scopes.getStore();
291
306
  if (!scope) {
@@ -376,7 +391,7 @@ export function guardTmuxExecution(input) {
376
391
  if (!parsed || parsed[1] !== scope.session)
377
392
  refuse(scope, "EXECUTION_CONFLICT");
378
393
  if (command !== "new-session")
379
- args[at + 1] = `${scope.createdNative ?? "=" + scope.session}${parsed[2] ?? ":"}`;
394
+ args[at + 1] = `${scope.managementCloseTarget ?? scope.createdNative ?? "=" + scope.session}${parsed[2] ?? ":"}`;
380
395
  }
381
396
  effects = true;
382
397
  }
@@ -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)
@@ -389,6 +413,28 @@ export class ExecutionService {
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)
@@ -564,7 +610,7 @@ export class ExecutionService {
564
610
  }
565
611
  sourceAdmission(state, source.point, args.source_session_id);
566
612
  const target = args.reservation.target;
567
- if (state.slots.some(slot => slot.session_id === target.session_id))
613
+ if (targetBlocked(state, args.reservation.request, target.session_id, target.expected_launch_id))
568
614
  throw new ExecutionError("EXECUTION_UNCONFIRMED", "possible");
569
615
  const current = await this.runtime.observe(target.session_id);
570
616
  this.expectTarget(current, target.expected_launch_id, target.session_id);
@@ -606,6 +652,7 @@ export class ExecutionService {
606
652
  }
607
653
  async invokeInternal(metaInput, stage, body, targetSessionId, deferred, admission) {
608
654
  const { request } = executionRefMetaSchema.parse(metaInput);
655
+ let expectedCloseNative;
609
656
  if (deferred && (!this.store.transactionSync || !this.runtime.observeSync))
610
657
  throw new ExecutionError("EXECUTION_UNSUPPORTED");
611
658
  await this.store.transaction(async (state) => {
@@ -625,6 +672,16 @@ export class ExecutionService {
625
672
  if (record.reservation.source.launch && !relation.source)
626
673
  throw new ExecutionError("EXECUTION_UNCONFIRMED");
627
674
  if (deferred) {
675
+ const target = record.reservation.target;
676
+ if (recoverableSends(state, request, target.session_id, target.expected_launch_id).length) {
677
+ if (targetBlocked(state, request, target.session_id, target.expected_launch_id))
678
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
679
+ this.expectTarget(await this.runtime.observe(target.session_id), target.expected_launch_id, target.session_id);
680
+ const native = this.runtime.nativeSessionSync?.(target.session_id);
681
+ if (native?.state !== "present" || !native.server)
682
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
683
+ expectedCloseNative = { native_id: native.native_id, server: structuredClone(native.server) };
684
+ }
628
685
  record.operations[stage] = "preparing";
629
686
  return; // no native effect or target slot yet; stop can cancel this
630
687
  }
@@ -654,6 +711,14 @@ export class ExecutionService {
654
711
  state.slots.push({ session_id: target.session_id, expected_launch_id: expected, stage, state: "issued", request });
655
712
  });
656
713
  let issued = !deferred, finished = false;
714
+ const checkCloseNative = (session) => {
715
+ if (!expectedCloseNative)
716
+ return;
717
+ const native = this.runtime.nativeSessionSync?.(session);
718
+ if (native?.state !== "present" || native.native_id !== expectedCloseNative.native_id
719
+ || !same(native.server, expectedCloseNative.server))
720
+ throw new ExecutionError("EXECUTION_UNCONFIRMED");
721
+ };
657
722
  const checkOrIssue = (commit, preparation) => {
658
723
  admission?.();
659
724
  const prepared = () => { admission?.(); return preparation(); };
@@ -688,6 +753,7 @@ export class ExecutionService {
688
753
  throw new ExecutionError("EXECUTION_STALE_GENERATION");
689
754
  }
690
755
  const current = this.runtime.observeSync(slot.session_id);
756
+ checkCloseNative(slot.session_id);
691
757
  if (current.state === "present" && stopped(state, current.launch))
692
758
  throw new ExecutionError("EXECUTION_STOPPING");
693
759
  if (slot.created_native_id) {
@@ -725,11 +791,12 @@ export class ExecutionService {
725
791
  throw new ExecutionError("EXECUTION_STALE_GENERATION");
726
792
  }
727
793
  const target = record.reservation.target;
728
- if (state.slots.some(slot => slot.session_id === target.session_id))
794
+ if (targetBlocked(state, request, target.session_id, target.expected_launch_id))
729
795
  throw new ExecutionError("EXECUTION_UNCONFIRMED");
730
796
  const expected = relation.target?.launch.launch_id ?? target.expected_launch_id;
731
797
  const current = this.runtime.observeSync(target.session_id);
732
798
  this.expectTarget(current, expected, target.session_id);
799
+ checkCloseNative(target.session_id);
733
800
  if (current.state === "present" && stopped(state, current.launch))
734
801
  throw new ExecutionError("EXECUTION_STOPPING");
735
802
  if ((stage === "launch" && current.state !== "absent") || (stage === "send" && current.state !== "present"))
@@ -830,8 +897,14 @@ export class ExecutionService {
830
897
  if (state.stopping.some(value => same(value, record.relation.source?.launch)) && !state.stopping.some(value => same(value, targetPoint.launch)))
831
898
  state.stopping.push(targetPoint.launch);
832
899
  state.slots = state.slots.filter(value => !same(value.request, request));
833
- if (closing)
900
+ if (closing) {
901
+ // Actual management close plus confirmed absence releases only these
902
+ // failed send admissions. Old callbacks still fail their missing-slot
903
+ // guard, and their records remain unknown and cannot be invoked again.
904
+ const recovered = recoverableSends(state, request, slot.session_id, expected);
905
+ state.slots = state.slots.filter(value => !recovered.includes(value));
834
906
  this.interruptRelations(state, targetPoint.launch);
907
+ }
835
908
  return view(state, record);
836
909
  }).catch(async () => {
837
910
  // The side effect already returned, but its result could not be safely
@@ -1326,6 +1399,8 @@ export class ExecutionService {
1326
1399
  interruptRelations(state, launch) {
1327
1400
  for (const record of state.records)
1328
1401
  if (same(record.relation.target?.launch, launch)) {
1402
+ if (record.operations.launch === "unknown" || record.operations.send === "unknown")
1403
+ continue;
1329
1404
  record.relation.completion = "interrupted";
1330
1405
  record.relation.target_fence = "closed";
1331
1406
  }
@@ -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,7 +119,12 @@ 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
129
  verifyServer: () => { if (!expectedServer)
125
130
  throw new ExecutionError("EXECUTION_UNSUPPORTED"); service.verifyCreationServer(ref, expectedServer); },
package/docs/DESIGN.md CHANGED
@@ -424,6 +424,15 @@ ADR 0074(repositoryの`docs/adr/0074-agent-model-catalog.md`)。
424
424
  境界失敗は明示errorにする。retry、別model、別harness、別backendへ自動fallbackしない。
425
425
  stale send lockは並行processとのABAを避けるため自動削除せず、公開APIでは対象sessionを`pty_close`して
426
426
  同じIDで再作成する。全session一括停止は公開しない。
427
+ POSIXの明示native execution構成では、返却済みsendのunknown枠が対象席の管理closeまで
428
+ 永久に拒否しないよう、通常`pty_close`だけを同じ期待launch/native IDで受付できる。
429
+ unknown send枠を保持したまま既存core closeへ進み、native close成功と席の不在を確認した後だけ
430
+ 該当send枠を解放する。元recordのphase/completion/sendはunknownのまま残し、再送や成功化をしない。
431
+ 旧callbackは未知/消失した元slotで拒否され、同IDの新launchへnative作用を発行できない。
432
+ issued、管理close自体のunknown、親作用のslot、世代/native/serverの不明・交代は対象外。
433
+ 既存の送信・待機lockの拒否も維持し、他席やissuerの停止を復旧条件へ追加しない。
434
+ native handlerのisErrorは元の本文を保持し、`aiterm/execution_error`のcode/effectを併記する。
435
+ native作用後の不明はpossibleのままで、原因説明を再送可否や成功receiptへ変換しない。
427
436
  agent sessionへの送信1本を守る`<name>.agent-send.lock`は持つ時間が長いので、持ち主が終了した残骸を次の送信が片付ける。消す役を`<name>.agent-send.lock.reap`の排他作成で1つに絞り、
428
437
  その中でもう一度確かめてから消すので、確かめた後で別のlockに入れ替わらない。消す役の印そのものが残った時だけは自動で消さず、`pty_close`を案内する。
429
438
  終了した持ち主の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.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": [