aiterm-mcp 0.26.0 → 0.27.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/README.md CHANGED
@@ -165,7 +165,7 @@ collection is off by default and performs no network I/O. It ships via
165
165
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
166
166
  Release re-registers the Official MCP Registry entry.
167
167
 
168
- **Status:** actively maintained · the newcomer here, betting on a different shape (see [vs. the alternatives](#vs-the-alternatives)) · runs on Linux · WSL2 · macOS · native Windows for the core PTY tools and Grok/Composer launches (Claude/Codex correlated completion is POSIX/WSL/macOS only for now) · MIT · see the [CHANGELOG](CHANGELOG.md).
168
+ **Status:** actively maintained · the newcomer here, betting on a different shape (see [vs. the alternatives](#vs-the-alternatives)) · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — all four launchers and correlated completion included, no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
169
169
 
170
170
  ## Why now
171
171
 
@@ -242,7 +242,7 @@ places its returned context before a fixed separator and the mission. This route
242
242
  `throughline >= 0.9.0`; it reads the source memory without changing that database's session
243
243
  ownership. No Throughline dependency is needed when the field is omitted.
244
244
 
245
- All four launchers forward `model` and `reasoning_effort` through public CLI flags. Explicit Grok/Composer models and Composer's default model are checked against the current `grok models` catalog before a PTY exists; missing models are errors, with no cache, retry, or fallback to another model. Pass an absolute path for `cwd` — `~` is not expanded. Durable callers can make a promptless Claude launch exactly replayable by passing an explicit `session_name` and `launch_operation_id`. Claude adds a launch-local settings file only for the correlated Stop hook and loads it together with normal `user,project,local` setting sources; it does not replace normal hooks, MCPs, plugins, permissions, or trust state. The hook event contains no answer body, and the bounded owner-only result is returned by `pty_read({ agent_transcript:true })` without reading Claude's private transcript. While a correlated Claude turn is active, raw sends and non-interrupt keys are rejected. Exact `/login` and `/logout` dispatches are rejected so shared authentication is repaired once in a normal terminal. Codex reads its normal rollout store; Grok/Composer read their normal session event/history files. Before dispatch, Codex waits for an idle TUI, while Grok/Composer additionally require the vendor's structured `mcp_init_completed` event so a visible input box cannot accept a prompt too early. Claude/Codex correlated completion requires POSIX filesystem semantics (Linux, WSL2, macOS); Grok/Composer correlated completion also works on native Windows, where the launcher starts the Windows-native `grok.exe` and reads its Windows-side session records.
245
+ All four launchers forward `model` and `reasoning_effort` through public CLI flags. Explicit Grok/Composer models and Composer's default model are checked against the current `grok models` catalog before a PTY exists; missing models are errors, with no cache, retry, or fallback to another model. Pass an absolute path for `cwd` — `~` is not expanded. Durable callers can make a promptless Claude launch exactly replayable by passing an explicit `session_name` and `launch_operation_id`. Claude adds a launch-local settings file only for the correlated Stop hook and loads it together with normal `user,project,local` setting sources; it does not replace normal hooks, MCPs, plugins, permissions, or trust state. The hook event contains no answer body, and the bounded owner-only result is returned by `pty_read({ agent_transcript:true })` without reading Claude's private transcript. While a correlated Claude turn is active, raw sends and non-interrupt keys are rejected. Exact `/login` and `/logout` dispatches are rejected so shared authentication is repaired once in a normal terminal. Codex reads its normal rollout store; Grok/Composer read their normal session event/history files. Before dispatch, Codex waits for an idle TUI, while Grok/Composer additionally require the vendor's structured `mcp_init_completed` event so a visible input box cannot accept a prompt too early. Correlated completion works for all four vendors on Linux, WSL2, macOS, and native Windows. On native Windows the Grok/Composer launchers start only the Windows-native `grok.exe` (a WSL-side grok is rejected before a session is created, so vendor auth and session records never split across an OS boundary), and Claude/Codex completion reads the same Windows-side hook/rollout records the vendors write.
246
246
 
247
247
  There is no hidden protocol between agents: a launched Claude, Codex, Grok, or Composer is another user-visible persistent terminal session. The MCP client drives that TUI with ordinary PTY operations, and a human can attach to watch or take over.
248
248
 
@@ -449,7 +449,7 @@ Each launcher starts a specific vendor's interactive coding-agent TUI inside a f
449
449
  | `grok_agent` | Grok Build, model `grok-4.6` by default (`model?` overrides) (xAI) | `prompt?`, `throughline_source_session?`, `model?`, `reasoning_effort?` (vendor-supported value), `env_vars?`, `cwd?`, `session_name?`, `write_scope?` |
450
450
  | `composer_agent` | Grok Build, model `grok-composer-2.5-fast` by default (`model?` overrides); every model is live-catalog checked (xAI) | `prompt?`, `throughline_source_session?`, `model?`, `reasoning_effort?` (vendor-supported value), `env_vars?`, `cwd?`, `session_name?`, `write_scope?` |
451
451
 
452
- The vendor CLI must be installed and authenticated (`claude` for `claude_agent`; `codex` for `codex_agent`; `grok` for both Grok tools). Binary resolution uses `CLAUDE_BIN` / `CODEX_BIN` / `GROK_BIN`, then each documented default location, then `PATH`. Missing binaries, invalid model/effort values, unavailable Grok/Composer catalog models, and nonexistent `cwd` fail before a session is created. Claude additionally requires a structured healthy authentication status before any PTY exists, and correlated Claude sessions reject `/login` and `/logout`; repair authentication once in a normal terminal. All four launchers share the normal project/user environment and the same non-blocking dispatch contract. Claude, Codex, Grok, and Composer depth-1 live smokes and a Claude depth-2 nested-delegation smoke are green; fixture coverage remains a separate claim. Native Windows can launch agents but correlated completion is not supported yet.
452
+ The vendor CLI must be installed and authenticated (`claude` for `claude_agent`; `codex` for `codex_agent`; `grok` for both Grok tools). Binary resolution uses `CLAUDE_BIN` / `CODEX_BIN` / `GROK_BIN`, then each documented default location, then `PATH`. Missing binaries, invalid model/effort values, unavailable Grok/Composer catalog models, and nonexistent `cwd` fail before a session is created. Claude additionally requires a structured healthy authentication status before any PTY exists, and correlated Claude sessions reject `/login` and `/logout`; repair authentication once in a normal terminal. All four launchers share the normal project/user environment and the same non-blocking dispatch contract. Claude, Codex, Grok, and Composer depth-1 live smokes and a Claude depth-2 nested-delegation smoke are green; fixture coverage remains a separate claim. Native Windows supports all four launchers with correlated completion (Claude verified with a live end-to-end launch on psmux ≥ 3.3.8; Grok launches only the Windows-native `grok.exe`).
453
453
 
454
454
  Set `throughline_source_session` together with a non-empty mission in `prompt` to prepend
455
455
  Throughline's read-only handoff context. This optional route requires `throughline >= 0.9.0`,
@@ -490,14 +490,14 @@ Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session
490
490
 
491
491
  ## A human can watch
492
492
 
493
- Sessions live on a shared tmux socket. The `tmux -S … attach -t <id>` line printed by `pty_open` (and by each agent launcher) lets a human attach to the same terminal and intervene (`Ctrl-b d` to detach) — including watching a launched Claude/Codex/Grok/Composer session run and taking the keyboard from your AI mid-task. On native Windows the printed line is the WSL form — `wsl tmux -S … attach -t <id>` — since the session lives inside WSL.
493
+ Sessions live on a shared tmux socket. The `tmux -S … attach -t <id>` line printed by `pty_open` (and by each agent launcher) lets a human attach to the same terminal and intervene (`Ctrl-b d` to detach) — including watching a launched Claude/Codex/Grok/Composer session run and taking the keyboard from your AI mid-task. On native Windows the printed line is the psmux form — `psmux -L <namespace> attach -t <id>` — pointing at the same Windows-native session.
494
494
 
495
495
  ## Requirements
496
496
 
497
497
  - **Node.js >= 18**
498
498
  - **tmux** (runtime prerequisite; check with `tmux -V`. Install with `apt install tmux` / `brew install tmux`)
499
499
  - **macOS / Linux / WSL2** run tmux directly. On macOS install it with `brew install tmux` (stock macOS ships none). If your MCP client is launched from the **GUI** rather than a terminal, Homebrew's bin (`/opt/homebrew/bin` on Apple Silicon, `/usr/local/bin` on Intel) may be off its `PATH`; aiterm auto-searches those locations, or set **`AITERM_TMUX=/path/to/tmux`** to point at it explicitly.
500
- - **Native Windows** has no tmux, so aiterm transparently runs tmux **inside WSL**. It needs [WSL](https://learn.microsoft.com/windows/wsl/) installed and initialized, with **tmux installed inside your WSL distro** (`sudo apt install tmux`); verify with `wsl tmux -V`. Sessions, the socket, and human `attach` all live on the WSL side — the AI just drives them from the Windows-side command. (You reach Windows tools the same way you reach SSH: `pty_send "powershell.exe …"` nests into PowerShell.) WSL stays a pure terminal transport: `grok_agent`/`composer_agent` launch the **Windows-native** Grok CLI (`%USERPROFILE%\.grok\bin\grok.exe`, or `GROK_BIN` pointing at a `.exe`) as a Windows process, and a WSL-side grok is rejected before a session is created so vendor auth and session records never split across the WSL boundary. aiterm keeps one long-lived WSL interop anchor process alive so Windows executables can start inside the WSL tmux pane.
500
+ - **Native Windows** has no tmux, so aiterm drives [psmux](https://github.com/psmux/psmux) — a tmux-CLI-compatible native multiplexer — with a per-install `-L` namespace. **No WSL is required.** Install psmux **3.3.8 or newer** (`winget install marlocarlo.psmux`; 3.3.8 is the first release whose `pipe-pane` file sink, byte-exact `paste-buffer` wire, and foreground `#{pane_current_command}` behave the way aiterm's capture/dispatch paths rely on), plus [Git for Windows](https://gitforwindows.org/) whose `bash.exe` becomes the pane shell (System32's `bash.exe` is the WSL launcher and is deliberately not used). Override resolution with **`AITERM_PSMUX`** / **`AITERM_BASH`** when the binaries live elsewhere. You reach Windows tools the same way you reach SSH: `pty_send "powershell.exe …"` nests into PowerShell. `grok_agent`/`composer_agent` launch the **Windows-native** Grok CLI (`%USERPROFILE%\.grok\bin\grok.exe`, or `GROK_BIN` pointing at a `.exe`) as a Windows process, and a WSL-side grok is rejected before a session is created so vendor auth and session records never split across an OS boundary.
501
501
  - For the **agent launchers**: the corresponding vendor CLI, installed and authenticated — `claude` for `claude_agent`, `codex` for `codex_agent`, `grok` for `grok_agent` / `composer_agent`. Portable fork additionally needs `throughline >= 0.9.0`; ordinary clean launch does not. (Not needed if you only use the PTY tools.)
502
502
  - Optional: the [`rtk`](https://github.com/rtk-ai/rtk) binary (used by `pty_send`'s `rtk: true` delegation; works fine without it)
503
503
 
@@ -525,8 +525,10 @@ Development uses focused local tests first. The final GitHub Actions gate starts
525
525
  `npm test` concurrently on self-hosted macOS native, Linux native, Windows native, and WSL2
526
526
  runners; it does not replace any OS with a reduced suite. Tag-triggered npm publishing runs only
527
527
  after all four environments pass and the tagged commit is confirmed on `origin/main`. The native
528
- Windows runner must run as the interactive Windows user that owns the initialized WSL distro;
529
- `NETWORK SERVICE` cannot see that user's WSL/tmux environment and is not a valid runner identity.
528
+ Windows runner needs psmux ≥ 3.3.8 and Git for Windows on its PATH, and must run as an
529
+ interactive Windows user; `NETWORK SERVICE` lacks the per-user environment the pane shell and
530
+ vendor CLIs rely on and is not a valid runner identity (the separate WSL2 runner still owns the
531
+ initialized WSL distro).
530
532
 
531
533
  Logic lives in `src/core.ts` (tmux control, reduction, completion detection, safety, agent launch) and `src/rtk.ts` (per-command reducers); `src/index.ts` is the MCP surface. The design origin and the reducer's porting source (the pytest reducer is ported to match upstream rtk 0.42.0, except the deliberate `FAILED`-line difference noted above, and is locked by regression tests) are in `prototype/python/`.
532
534
 
@@ -22,8 +22,10 @@ function hasAitermEnv() {
22
22
  process.env.AITERM_AGENT_LAUNCH_ID);
23
23
  }
24
24
  function uid() {
25
+ // state root のパス構成要素 `aiterm-mcp-<uid>` にだけ使う。Windows(native) は
26
+ // process.getuid を持たないため core の currentUid() と同じ受容で 0 を返す。
25
27
  if (typeof process.getuid !== "function")
26
- fail("POSIX getuid が使えません");
28
+ return 0;
27
29
  return process.getuid();
28
30
  }
29
31
  function runtimeStateBase() {
@@ -39,18 +41,13 @@ function runtimeStateBase() {
39
41
  }
40
42
  return os.tmpdir();
41
43
  }
42
- function secureAgentsDir() {
44
+ function agentsDir() {
45
+ // state root は OS が与える per-user runtime dir(XDG_RUNTIME_DIR / os.tmpdir())の下にある。
46
+ // 以前はここで symlink・owner・mode を検査していたが、共有 /tmp に敵対的な同居主体がいる
47
+ // 前提の防御であり、対応 OS の既定配置では成立しない(オーナー裁定 2026-08-19)。
48
+ // 経路の異常は open/stat の OS エラーとしてそのまま露出させる。
43
49
  const root = path.join(runtimeStateBase(), `aiterm-mcp-${uid()}`);
44
- const agents = path.join(root, "agents");
45
- const rst = fs.lstatSync(root);
46
- if (!rst.isDirectory() || rst.isSymbolicLink() || rst.uid !== uid() || (rst.mode & 0o077) !== 0) {
47
- fail(`agent state root が安全ではありません: ${root}`);
48
- }
49
- const ast = fs.lstatSync(agents);
50
- if (!ast.isDirectory() || ast.isSymbolicLink() || ast.uid !== uid() || (ast.mode & 0o077) !== 0) {
51
- fail(`agent state dir が安全ではありません: ${agents}`);
52
- }
53
- return agents;
50
+ return path.join(root, "agents");
54
51
  }
55
52
  async function readStdin() {
56
53
  const chunks = [];
@@ -69,12 +66,8 @@ function writeResult(file, value) {
69
66
  if (Buffer.byteLength(body, "utf8") > MAX_STDIN_BYTES)
70
67
  fail("result file が大きすぎます");
71
68
  const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
72
- const nofollow = fs.constants.O_NOFOLLOW ?? 0;
73
- const fd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
69
+ const fd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
74
70
  try {
75
- const st = fs.fstatSync(fd);
76
- if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0)
77
- fail("result temp file が安全ではありません");
78
71
  const expected = Buffer.byteLength(body, "utf8");
79
72
  const written = fs.writeSync(fd, body, undefined, "utf8");
80
73
  if (written !== expected)
@@ -97,16 +90,13 @@ function writeResult(file, value) {
97
90
  }
98
91
  }
99
92
  function appendEvent(file, event) {
100
- const nofollow = fs.constants.O_NOFOLLOW ?? 0;
101
93
  const line = JSON.stringify(event) + "\n";
102
94
  if (Buffer.byteLength(line, "utf8") > 64 * 1024)
103
95
  fail("event line が大きすぎます");
104
- const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_WRONLY | nofollow, 0o600);
96
+ const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_WRONLY, 0o600);
105
97
  try {
98
+ // st は短書き込み時の巻き戻し(ftruncate)に使う。安全性検査としては使わない。
106
99
  const st = fs.fstatSync(fd);
107
- if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
108
- fail(`event file が安全ではありません: ${file}`);
109
- }
110
100
  const expected = Buffer.byteLength(line, "utf8");
111
101
  const written = fs.writeSync(fd, line, undefined, "utf8");
112
102
  if (written !== expected) {
@@ -120,10 +110,9 @@ function appendEvent(file, event) {
120
110
  }
121
111
  }
122
112
  function readOperationMarker(file) {
123
- const nofollow = fs.constants.O_NOFOLLOW ?? 0;
124
113
  let fd;
125
114
  try {
126
- fd = fs.openSync(file, fs.constants.O_RDONLY | nofollow);
115
+ fd = fs.openSync(file, fs.constants.O_RDONLY);
127
116
  }
128
117
  catch (error) {
129
118
  if (error.code === "ENOENT")
@@ -132,8 +121,9 @@ function readOperationMarker(file) {
132
121
  }
133
122
  try {
134
123
  const st = fs.fstatSync(fd);
135
- if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0 || st.size > 1024) {
136
- fail(`operation markerが安全ではありません: ${file}`);
124
+ // parse する入力の上限だけ残す(owner・link 数・mode の検査は撤去した)。
125
+ if (st.size > 1024) {
126
+ fail(`operation markerが大きすぎます: ${file}`);
137
127
  }
138
128
  const body = fs.readFileSync(fd, "utf8");
139
129
  const value = JSON.parse(body);
@@ -161,13 +151,9 @@ function consumeOperationMarker(file, marker) {
161
151
  catch {
162
152
  fail("operation markerが完了記録中に消失しました");
163
153
  }
164
- if (!st.isFile() ||
165
- st.isSymbolicLink() ||
166
- st.uid !== uid() ||
167
- st.nlink !== 1 ||
168
- (st.mode & 0o077) !== 0 ||
169
- st.dev !== marker.dev ||
170
- st.ino !== marker.ino) {
154
+ // dev/ino は「先に stat したのと同じ実体か」という同一性の検査であり、operation 相関の
155
+ // 正しさそのもの。撤去した安全設備(symlink・owner・link 数・mode)とは別物なので残す。
156
+ if (st.dev !== marker.dev || st.ino !== marker.ino) {
171
157
  fail("operation markerが完了記録中に置換されました");
172
158
  }
173
159
  fs.unlinkSync(file);
@@ -202,7 +188,7 @@ async function main() {
202
188
  if (resultBytes > MAX_RESULT_BYTES)
203
189
  fail(`assistant result が${MAX_RESULT_BYTES} bytesを超えています`);
204
190
  const resultDigest = createHash("sha256").update(text, "utf8").digest("hex");
205
- const agents = secureAgentsDir();
191
+ const agents = agentsDir();
206
192
  const resultFile = path.join(agents, `${session}.${launchId}.claude-result.json`);
207
193
  const eventFile = path.join(agents, `${session}.${launchId}.events.jsonl`);
208
194
  const operationFile = path.join(agents, `${session}.${launchId}.claude-operation.json`);
package/dist/core.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * 設計: docs/01_design-plan.md / docs/02_mcp-plan.md。出力削減は rag/ の RTK を移植。
9
9
  */
10
- import { spawn, spawnSync } from "node:child_process";
10
+ import { spawnSync } from "node:child_process";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import * as os from "node:os";
@@ -15,19 +15,18 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import * as rtk from "./rtk.js";
17
17
  import { recordRuntimeError } from "./runtime-error-store.js";
18
- // Windows ネイティブには tmux が無い。その場合だけ全 tmux 呼び出しを WSL 経由へ橋渡しする
19
- // (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く)。
18
+ // Windows ネイティブには tmux が無いため、tmux CLI 互換の native psmux を叩く
19
+ // (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く。WSL 橋は e3f5fc8 で全廃)。
20
20
  const isWin = process.platform === "win32";
21
21
  // .log/.offset/.lastcmd を置くディレクトリ(Node が直接読み書きする)。
22
22
  // POSIX は従来どおり。Windows は TMPDIR→TEMP→os.tmpdir() の順で Windows 側の一時領域に置く。
23
23
  const SOCKDIR = path.join(process.env.TMPDIR ?? (isWin ? process.env.TEMP ?? os.tmpdir() : "/tmp"), "claude-tmux-sockets");
24
- // tmux -S に渡すソケットパス。POSIX はログと同じツリーに置く。
25
- // Windows は tmux が WSL 内で動くため、ソケットは WSL ネイティブ fs に置く必要がある
26
- // (/mnt drvfs(9p) 上では AF_UNIX 非対応)。SOCKDIR から短い安定名を導出し、
27
- // TMPDIR ごとの隔離(テスト)と再起動跨ぎ再接続を両立する。
28
- const SOCK = isWin
29
- ? `/tmp/aiterm-${createHash("sha1").update(SOCKDIR).digest("hex").slice(0, 12)}.sock`
30
- : path.join(SOCKDIR, "claude.sock");
24
+ // tmux -S に渡すソケットパス(POSIX はログと同じツリーに置く)。
25
+ // Windows は native psmux を使い、-S でなく -L server namespace で隔離する
26
+ // (psmux の -S は黙って既定 namespace へ落ちるため使わない・実測 2026-08-16)。
27
+ // SOCKDIR から短い安定名を導出し、TMPDIR ごとの隔離(テスト)と再起動跨ぎ再接続を両立する。
28
+ const SOCK = path.join(SOCKDIR, "claude.sock");
29
+ const WIN_NS = `aiterm-${createHash("sha1").update(SOCKDIR).digest("hex").slice(0, 12)}`;
31
30
  // 完了検出
32
31
  export const DEFAULT_TIMEOUT = 10.0;
33
32
  const POLL = 0.25;
@@ -156,110 +155,26 @@ function ptyDependencyError(message, observe = true) {
156
155
  ownTelemetryFailure("AITERM.PTY_DEPENDENCY_UNAVAILABLE", error, 2);
157
156
  throw error;
158
157
  }
159
- // Windows のドライブパス (C:\a\b) を WSL から見える /mnt/c/a/b へ変換する。
160
- // 一時領域は常にドライブ直下なので UNC は想定外=弾く(黙って壊れた //server パスを作らない)。
161
- export function toWslPath(p) {
162
- const m = /^([A-Za-z]):\/(.*)$/.exec(p.replace(/\\/g, "/"));
163
- if (!m)
164
- throw new AitermError(`WSL へ橋渡しできない一時パスです(ドライブ直下のみ対応): ${p}`, 2);
165
- return `/mnt/${m[1].toLowerCase()}/${m[2]}`;
166
- }
167
- // Windows で最初の tmux 呼び出し前に一度だけ WSL+tmux の可用性を確かめ、失敗は原因別に投げる。
168
- // -e(ログインシェル非経由)+短い timeout で、初回セットアップ未完了の wsl によるハングも防ぐ。
169
- let winBridgeOk = false;
170
- function ensureWinBridge(observe = true) {
171
- if (winBridgeOk)
158
+ // Windows で最初の呼び出し前に一度だけ native psmux の可用性を確かめ、失敗は原因別に投げる。
159
+ // psmux は tmux CLI 互換の Windows ネイティブ実装(ConPTY・WSL 不要)。AITERM_PSMUX で
160
+ // バイナリを明示上書きできる(POSIX の AITERM_TMUX に対応)。
161
+ function psmuxBin() {
162
+ return process.env.AITERM_PSMUX || "psmux";
163
+ }
164
+ let winPsmuxOk = false;
165
+ function ensureWinPsmux(observe = true) {
166
+ if (winPsmuxOk)
172
167
  return;
173
- const r = spawnSync("wsl.exe", ["-e", "tmux", "-V"], { encoding: "utf8", timeout: 10000 });
168
+ const r = spawnSync(psmuxBin(), ["-V"], { encoding: "utf8", timeout: 10000 });
174
169
  if (r.error) {
175
170
  const code = r.error.code;
176
- if (code === "ETIMEDOUT")
177
- ptyDependencyError("WSL が応答しません(初回セットアップ未完了の可能性)。一度 `wsl` を起動してから再実行してください。", observe);
178
171
  if (code === "ENOENT")
179
- ptyDependencyError("wsl.exe が見つかりません。Windows では WSL 上の tmux 経由で動作します。WSL と tmux を導入してください。", observe);
180
- ptyDependencyError(`wsl.exe を起動できませんでした(${code ?? "unknown"})。`, observe);
172
+ ptyDependencyError("psmux が見つかりません。Windows ネイティブでは psmux が必要です(導入例: winget install marlocarlo.psmux)。既存の psmux を使う場合は AITERM_PSMUX でパスを指定してください。", observe);
173
+ ptyDependencyError(`psmux を起動できませんでした(${code ?? "unknown"})。`, observe);
181
174
  }
182
- // wsl.exe は System32 にあるので「起動」は成功するが、ディストリ未導入や distro 内に tmux が無いと
183
- // 非ゼロで終わる。両方を区別せず(wsl の出力は UTF-16 で文字化けし得るため)正直に表す。
184
175
  if (r.status !== 0)
185
- ptyDependencyError("WSL 経由で tmux を起動できませんでした。WSL のディストリ未導入、または distro 内に tmux が無い可能性があります。`wsl tmux -V` が通るか確認してください(tmux 導入例: sudo apt install tmux)。", observe);
186
- winBridgeOk = true;
187
- }
188
- // Windows の tmux bridge では各 wsl.exe 呼び出しが短命で、tmux server と pane が継承する
189
- // WSL_INTEROP は起動元 WSL session の終了とともに死ぬ。死んだ socket のまま pane 内から
190
- // Windows .exe を起動すると binfmt interop が UtilAcceptVsock accept4=110(ETIMEDOUT) で失敗する。
191
- // 生きた session leader の socket を WSL_INTEROP へ指せば同じ pane で成功する(どちらも実測
192
- // 2026-08-15・WSL 2.6.1)。そのため aiterm が長寿命 anchor(sleep する wsl.exe process)を
193
- // 1本所有し、native .exe 起動の env へその socket を供給する。anchor は起動時に自身の
194
- // WSL_INTEROP と pid を state file へ書き、以後は生存確認だけで再利用する。
195
- const WIN_INTEROP_ANCHOR_SPAWN_TIMEOUT_MS = 10_000;
196
- const WIN_INTEROP_ANCHOR_POLL_MS = 100;
197
- function winInteropAnchorStatePath() {
198
- return path.join(ensureSecureStateRoot(), "interop-anchor.json");
199
- }
200
- function readWinInteropAnchorState() {
201
- let raw;
202
- try {
203
- raw = fs.readFileSync(winInteropAnchorStatePath(), "utf8");
204
- }
205
- catch {
206
- return null;
207
- }
208
- try {
209
- const value = JSON.parse(raw);
210
- if (typeof value.socket === "string" &&
211
- value.socket.startsWith("/run/WSL/") &&
212
- Number.isSafeInteger(value.pid) &&
213
- value.pid > 0) {
214
- return { socket: value.socket, pid: value.pid };
215
- }
216
- }
217
- catch {
218
- /* 不完全・破損 state は下の再生成経路で作り直す */
219
- }
220
- return null;
221
- }
222
- function isWinInteropAnchorAlive(state) {
223
- const r = spawnSync("wsl.exe", ["-e", "sh", "-c", `test -S ${shq(state.socket)} && kill -0 ${state.pid}`], {
224
- encoding: "utf8",
225
- timeout: 10000,
226
- windowsHide: true,
227
- });
228
- return !r.error && r.status === 0;
229
- }
230
- // openAgent は同期経路のため、anchor の起動待ちだけ Atomics.wait で有界に同期 sleep する。
231
- function sleepSyncMs(ms) {
232
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
233
- }
234
- function ensureWinInteropAnchor() {
235
- const existing = readWinInteropAnchorState();
236
- if (existing && isWinInteropAnchorAlive(existing))
237
- return existing.socket;
238
- const statePath = winInteropAnchorStatePath();
239
- fs.rmSync(statePath, { force: true });
240
- // 並行launchが同時にここへ来ると anchor が余分に1本立つが、state は後着が上書きし
241
- // どちらの socket も有効なので実害はない(余剰 process は wsl shutdown まで sleep のみ)。
242
- const child = spawn("wsl.exe", [
243
- "-e",
244
- "sh",
245
- "-c",
246
- `printf '{"socket":"%s","pid":%s}' "$WSL_INTEROP" "$$" > ${shq(toWslPath(statePath))} && exec sleep infinity`,
247
- ], { detached: true, stdio: "ignore", windowsHide: true });
248
- child.unref();
249
- const deadline = Date.now() + WIN_INTEROP_ANCHOR_SPAWN_TIMEOUT_MS;
250
- for (;;) {
251
- const state = readWinInteropAnchorState();
252
- if (state) {
253
- if (!isWinInteropAnchorAlive(state)) {
254
- throw new AitermError("WSL interop anchor が起動直後に停止しました。`wsl` が動作するか確認してください。", 2);
255
- }
256
- return state.socket;
257
- }
258
- if (Date.now() >= deadline) {
259
- throw new AitermError("WSL interop anchor を起動できません。`wsl` が動作するか確認してください。", 2);
260
- }
261
- sleepSyncMs(WIN_INTEROP_ANCHOR_POLL_MS);
262
- }
176
+ ptyDependencyError("psmux -V が失敗しました。`psmux -V` が通るか確認してください。", observe);
177
+ winPsmuxOk = true;
263
178
  }
264
179
  // tmux が見つからないときの説明。macOS は tmux を同梱せず、Homebrew の bin は GUI 起動時の PATH に
265
180
  // 入らないため、原因と対処(導入・自動探索・AITERM_TMUX 上書き)を正直に提示する。
@@ -323,12 +238,12 @@ export function tmuxSpawnEnv() {
323
238
  function tmuxCommandWithInput(observe, input, ...args) {
324
239
  // maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
325
240
  // 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
326
- // Windows は同じ tmux を WSL 経由(-e でログインシェル非経由=$ 展開やクオート崩れを防ぐ)で叩く。
241
+ // Windows は tmux CLI 互換の native psmux を -L namespace 隔離で叩く(WSL 非依存)。
327
242
  let r;
328
243
  const spawnOpts = { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, input, env: tmuxSpawnEnv() };
329
244
  if (isWin) {
330
- ensureWinBridge(observe);
331
- r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], spawnOpts);
245
+ ensureWinPsmux(observe);
246
+ r = spawnSync(psmuxBin(), ["-L", WIN_NS, ...args], spawnOpts);
332
247
  }
333
248
  else {
334
249
  // resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
@@ -364,14 +279,23 @@ function sessionExists(name) {
364
279
  }
365
280
  function paneCurrentCommand(name) {
366
281
  const r = tmux("display-message", "-p", "-t", name, "#{pane_current_command}");
367
- return r.code === 0 ? r.stdout.trim() : "";
282
+ if (r.code !== 0)
283
+ return "";
284
+ const cmd = r.stdout.trim();
285
+ // Windows(psmux)は "bash.exe" やフルパス形で報告しうる。SHELLS 等の POSIX 名
286
+ // 集合と突合できるよう、basename・.exe 除去・小文字化へ正規化する(Windows の
287
+ // 実行ファイル名は case-insensitive)。POSIX は従来どおり無加工。
288
+ if (!isWin)
289
+ return cmd;
290
+ return path.basename(cmd).replace(/\.exe$/i, "").toLowerCase();
368
291
  }
369
292
  function pasteBufferSupportsNoSanitizeFlag() {
370
293
  const listed = tmux("list-commands");
371
294
  if (listed.code !== 0) {
372
295
  throw new AitermError(`tmux paste-buffer 能力の確認に失敗しました: ${listed.stderr.trim() || `code=${listed.code}`}`, 2);
373
296
  }
374
- const usage = listed.stdout.split("\n").find((line) => line.startsWith("paste-buffer "));
297
+ // psmux の list-commands は行頭にインデントを持つため trim して突合する。
298
+ const usage = listed.stdout.split("\n").find((line) => line.trim().startsWith("paste-buffer"));
375
299
  if (!usage)
376
300
  throw new AitermError("tmux list-commands にpaste-bufferがありません", 2);
377
301
  // tmux 3.4は制御文字を無変換でpasteし、-S自体が無い。3.7は既定でvis(3)変換し、
@@ -450,31 +374,19 @@ function stateRoot() {
450
374
  const base = runtimeStateBase();
451
375
  return path.join(base, `aiterm-mcp-${uid}`);
452
376
  }
453
- function ensureSecureStateRoot() {
377
+ function ensureStateRoot() {
378
+ // state root は OS が与える per-user runtime dir(XDG_RUNTIME_DIR / os.tmpdir())の下に作る。
379
+ // 以前はここで symlink・owner・mode を検査していたが、共有 /tmp に敵対的な同居主体がいる
380
+ // 前提の防御であり、対応 OS の既定配置では成立しない(オーナー裁定 2026-08-19)。
381
+ // 作成時の 0o700 は検査ではなく妥当な既定として残す。経路の異常は以降の
382
+ // open/stat が OS エラーとしてそのまま露出させる。
454
383
  const root = stateRoot();
455
384
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
456
- const st = fs.lstatSync(root);
457
- if (!st.isDirectory() || st.isSymbolicLink()) {
458
- throw new AitermError(`agent state root が安全な directory ではありません: ${root}`, 2);
459
- }
460
- if (st.uid !== currentUid()) {
461
- throw new AitermError(`agent state root の owner が現在ユーザーではありません: ${root}`, 2);
462
- }
463
- if ((st.mode & 0o077) !== 0) {
464
- fs.chmodSync(root, 0o700);
465
- }
466
- const agents = path.join(root, "agents");
467
- fs.mkdirSync(agents, { recursive: true, mode: 0o700 });
468
- const ast = fs.lstatSync(agents);
469
- if (!ast.isDirectory() || ast.isSymbolicLink() || ast.uid !== currentUid()) {
470
- throw new AitermError(`agent state dir が安全な directory ではありません: ${agents}`, 2);
471
- }
472
- if ((ast.mode & 0o077) !== 0)
473
- fs.chmodSync(agents, 0o700);
385
+ fs.mkdirSync(path.join(root, "agents"), { recursive: true, mode: 0o700 });
474
386
  return root;
475
387
  }
476
388
  function agentsDir() {
477
- return path.join(ensureSecureStateRoot(), "agents");
389
+ return path.join(ensureStateRoot(), "agents");
478
390
  }
479
391
  function agentEventPath(name, launchId) {
480
392
  assertSessionName(name);
@@ -526,24 +438,14 @@ function agentClaudeDispatchReceiptPath(name, launchId, operationId) {
526
438
  return path.join(agentsDir(), `${name}.${launchId}.${validated.slice("sha256:".length)}.claude-dispatch`);
527
439
  }
528
440
  function existingAgentsDir() {
529
- // Windows は getuid を持たないが、currentUid() が 0 を返し fs.Stats.uid も常に 0 のため
530
- // owner 比較は自然に通過する(currentUid の既知制約受容と同じ)。以前はここで null を返して
531
- // いたため、Windows では close/killAll の agent state 掃除が常に no-op になり、同名 session の
532
- // 再起動が「agent metadata が複数あります」で失敗していた(実被弾 2026-08-15)。
533
- const root = path.join(runtimeStateBase(), `aiterm-mcp-${currentUid()}`);
534
- const dir = path.join(root, "agents");
441
+ // 既存の agents dir があればその path、無ければ null(作成はしない)。
442
+ // 以前はここで symlink・owner・mode を検査していたが撤去した(オーナー裁定 2026-08-19)。
443
+ // なお「Windows で常に null を返す」形だった頃は close/killAll の agent state 掃除が
444
+ // 常に no-op になり、同名 session の再起動が「agent metadata が複数あります」で
445
+ // 失敗していた(実被弾 2026-08-15)。存在判定だけに絞ることでその轍も踏まない。
446
+ const dir = path.join(runtimeStateBase(), `aiterm-mcp-${currentUid()}`, "agents");
535
447
  try {
536
- const rst = fs.lstatSync(root);
537
- if (!rst.isDirectory() || rst.isSymbolicLink() || rst.uid !== currentUid())
538
- return null;
539
- if (!isWin && (rst.mode & 0o077) !== 0)
540
- fs.chmodSync(root, 0o700);
541
- const st = fs.lstatSync(dir);
542
- if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== currentUid())
543
- return null;
544
- if (!isWin && (st.mode & 0o077) !== 0)
545
- fs.chmodSync(dir, 0o700);
546
- return dir;
448
+ return fs.statSync(dir).isDirectory() ? dir : null;
547
449
  }
548
450
  catch {
549
451
  return null;
@@ -631,8 +533,8 @@ function readLastcmd(name) {
631
533
  }
632
534
  }
633
535
  export function attachHint(name) {
634
- // Windows は WSL 内 tmux なので、人が打つのは wsl 経由(SOCK は WSL パス)。
635
- const cmd = isWin ? `wsl tmux -S ${SOCK} attach -t ${name}` : `tmux -S ${SOCK} attach -t ${name}`;
536
+ // Windows は native psmux(tmux CLI 互換)を -L namespace で叩く。
537
+ const cmd = isWin ? `psmux -L ${WIN_NS} attach -t ${name}` : `tmux -S ${SOCK} attach -t ${name}`;
636
538
  return (`このセッションを自分の目で見る/介入する:\n` +
637
539
  ` ${cmd}\n` +
638
540
  ` (抜けるには Ctrl-b d)`);
@@ -753,9 +655,10 @@ function captureScreen(name, lines) {
753
655
  return r.code === 0 ? r.stdout : "";
754
656
  }
755
657
  const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
756
- // Windows 専用: pipe-pane のログは /mnt/c (9p) 境界を越えて書かれるため、tmux が完了/セッション
757
- // 消滅を報告した後も最後の数百バイトが少し遅れて現れる。完了と判定する直前にログサイズが
758
- // 伸びなくなるまで待ち、readOutput が末尾欠けの出力を返さないようにする(POSIX は同一fsゆえ不要)。
658
+ // Windows 専用: pipe-pane のログは psmux server 側の in-process sink が書く=完了検知と
659
+ // 書き手が別 process のため、完了/セッション消滅の報告後も末尾数百バイトが遅れて現れうる。
660
+ // 完了と判定する直前にログサイズが伸びなくなるまで待ち、readOutput が末尾欠けの出力を
661
+ // 返さないようにする(POSIX の tmux は /bin/sh sink・同一 fs で実測上不要)。
759
662
  async function settleWinLog(name) {
760
663
  let prev = -1;
761
664
  for (let i = 0; i < 8; i++) {
@@ -908,11 +811,8 @@ function completionSuffix(status) {
908
811
  function rtkRewrite(text) {
909
812
  if (text.trim().includes("\n"))
910
813
  return text;
911
- // timeout 無しだと rtk がハングしたとき send() ごと凍結する。Python は timeout=5。
912
- // Windows はコマンドが WSL 内で走るので rtk も WSL 側(-e)で評価する。不在は素通し。
913
- const r = isWin
914
- ? spawnSync("wsl.exe", ["-e", "rtk", "rewrite", text], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 })
915
- : spawnSync("rtk", ["rewrite", text], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 });
814
+ // timeout 無しだと rtk がハングしたとき send() ごと凍結する。Python は timeout=5。不在は素通し。
815
+ const r = spawnSync("rtk", ["rewrite", text], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 });
916
816
  if (r.error)
917
817
  return text; // rtk 不在(ENOENT)・タイムアウト(ETIMEDOUT)等は元テキストへ素通し
918
818
  if ((r.status === 0 || r.status === 3) && (r.stdout ?? "").trim())
@@ -928,7 +828,37 @@ function assertNotDestructive(text, code, context = "") {
928
828
  }
929
829
  }
930
830
  // ---------------------------------------------------------------- 操作(return で返す / 失敗は AitermError)
831
+ // Windows native pane の既定 shell 解決。裸の "bash" は PATH 上で System32 の
832
+ // WSL launcher (bash.exe) に解決されてしまうため、Git for Windows の bash.exe を
833
+ // 明示解決する(WSL 非依存の裁定に従う)。AITERM_BASH で上書き可。
834
+ function resolveWinPaneShell(shell) {
835
+ if (!isWin || shell !== "bash")
836
+ return shell;
837
+ const fromEnv = process.env.AITERM_BASH;
838
+ if (fromEnv) {
839
+ if (isUsableExecutableFile(fromEnv))
840
+ return fromEnv;
841
+ throw new AitermError(`AITERM_BASH に指定された bash が存在しません: ${fromEnv}`, 2);
842
+ }
843
+ const candidates = [
844
+ path.join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
845
+ path.join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
846
+ ];
847
+ const gitPath = spawnSync("where.exe", ["git.exe"], { encoding: "utf8", timeout: 5000 })
848
+ .stdout?.split(/\r?\n/)
849
+ .find(Boolean);
850
+ if (gitPath)
851
+ candidates.push(path.join(path.dirname(path.dirname(gitPath)), "bin", "bash.exe"));
852
+ for (const cand of candidates) {
853
+ if (isUsableExecutableFile(cand))
854
+ return cand;
855
+ }
856
+ throw new AitermError("Git Bash が見つかりません。Windows ネイティブの pane shell には Git for Windows の bash.exe が必要です" +
857
+ "(System32 の bash.exe は WSL launcher のため使いません)。Git for Windows を導入するか、" +
858
+ "AITERM_BASH に bash.exe のパスを指定してください。", 2);
859
+ }
931
860
  export function openSession(name, shell = "bash") {
861
+ shell = resolveWinPaneShell(shell);
932
862
  try {
933
863
  fs.mkdirSync(SOCKDIR, { recursive: true });
934
864
  }
@@ -955,7 +885,8 @@ export function openSession(name, shell = "bash") {
955
885
  throw new AitermError(`session '${nm}' は既に存在します(list で確認)`, 2);
956
886
  }
957
887
  else {
958
- const r = tmux("new-session", "-d", "-s", nm, ...banner, "-f", "/dev/null", shell);
888
+ // -f は端末個人の設定ファイルを読まないための空 config(Windows は NUL デバイス)。
889
+ const r = tmux("new-session", "-d", "-s", nm, ...banner, "-f", isWin ? "NUL" : "/dev/null", shell);
959
890
  if (r.code === 0)
960
891
  break;
961
892
  // 自動採番かつ「重複名」由来の失敗(他エージェントが同名を先に取った)なら次名でリトライ。
@@ -986,8 +917,10 @@ export function openSession(name, shell = "bash") {
986
917
  cleanupAgentState(nm);
987
918
  // pipe-pane の引数は tmux 内部の /bin/sh -c で再解釈される(argv ではない)。パスは単一引用符で包み、
988
919
  // パス自身の ' は '\'' イディオムでエスケープする(名前は検証済みだが、Windows ユーザー名 O'Brien 等が
989
- // 一時パスに ' を持ち込み redirect を壊すのを防ぐ。空白対策も兼ねる)。Windows は WSL から見える /mnt/c 形へ。
990
- const pipeTarget = isWin ? toWslPath(logpath(nm)) : logpath(nm);
920
+ // 一時パスに ' を持ち込み redirect を壊すのを防ぐ。空白対策も兼ねる)。
921
+ // Windows の psmux は `cat > <path>` を in-process の直接ファイルsinkとして処理する
922
+ // (psmux e3e4b71 以降)。パスは Windows 形のまま渡す。
923
+ const pipeTarget = logpath(nm);
991
924
  const quoted = `'${pipeTarget.replace(/'/g, "'\\''")}'`;
992
925
  const pr = tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
993
926
  if (pr.code !== 0) {
@@ -1096,7 +1029,25 @@ export function send(name, text, o = {}) {
1096
1029
  const partial = i > 0
1097
1030
  ? " 先行chunkはPTYに入力済みでEnterは未送信です。再送前に入力を確認・消去してください。"
1098
1031
  : "";
1099
- const loaded = tmuxWithInput(chunks[i], "load-buffer", "-b", bufferName, "-");
1032
+ // psmux の load-buffer は stdin (`-`) 非対応で path 位置引数だけを取る。
1033
+ // Windows は owner-only の SOCKDIR 内へ一時ファイル経由で渡す。
1034
+ let loaded;
1035
+ if (isWin) {
1036
+ const chunkFile = path.join(SOCKDIR, `${bufferName}.chunk`);
1037
+ try {
1038
+ fs.writeFileSync(chunkFile, chunks[i], { encoding: "utf8" });
1039
+ loaded = tmux("load-buffer", "-b", bufferName, chunkFile);
1040
+ }
1041
+ finally {
1042
+ try {
1043
+ fs.unlinkSync(chunkFile);
1044
+ }
1045
+ catch { /* noop */ }
1046
+ }
1047
+ }
1048
+ else {
1049
+ loaded = tmuxWithInput(chunks[i], "load-buffer", "-b", bufferName, "-");
1050
+ }
1100
1051
  if (loaded.code !== 0) {
1101
1052
  tmuxCleanup("delete-buffer", "-b", bufferName);
1102
1053
  throw new AitermError(`tmux bufferへの送信準備に失敗しました` +
@@ -1104,7 +1055,8 @@ export function send(name, text, o = {}) {
1104
1055
  }
1105
1056
  // -r: LF→CR 置換を無効化。-Sは対応新版だけでvis(3)制御文字変換を無効化する。
1106
1057
  // send 自身の raw/sanitize 契約だけを真実とし、tmux 側で黙って再変換させない。
1107
- const pasteArgs = ["paste-buffer", "-d", "-r"];
1058
+ // psmux は -r 非対応(受理フラグは d/p/b/t のみ)のため Windows では付けない。
1059
+ const pasteArgs = isWin ? ["paste-buffer", "-d"] : ["paste-buffer", "-d", "-r"];
1108
1060
  if (o.bracketedPaste)
1109
1061
  pasteArgs.push("-p");
1110
1062
  if (pasteSupportsNoSanitize)
@@ -1257,7 +1209,7 @@ export async function readOutput(name, o = {}) {
1257
1209
  }
1258
1210
  else {
1259
1211
  let off = readOffset(name);
1260
- // WSL 再起動等でログが作り直されると、Windows 側に残った旧 offset が新ログ長を超え、
1212
+ // psmux server 再起動等でログが作り直されると、残った旧 offset が新ログ長を超え、
1261
1213
  // 空を返して「何も読めない」状態になる。末尾越えは先頭から読み直す(POSIX では no-op)。
1262
1214
  if (off > size)
1263
1215
  off = 0;
@@ -1339,7 +1291,7 @@ export function readOnlyPtyListDiagnostic(runTmux = tmux) {
1339
1291
  }
1340
1292
  }
1341
1293
  catch {
1342
- // tmux 未導入・WSL bridge 不全等。絶対 path や生 stderr を診断 JSON に出さない。
1294
+ // tmux/psmux 未導入等。絶対 path や生 stderr を診断 JSON に出さない。
1343
1295
  }
1344
1296
  return { status: "unverified", session_count: null };
1345
1297
  }
@@ -1604,9 +1556,10 @@ function writeText0600(p, text) {
1604
1556
  /* noop */
1605
1557
  }
1606
1558
  }
1607
- function createEmpty0600NoFollow(p) {
1608
- const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1609
- const fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
1559
+ function createEmpty0600(p) {
1560
+ // O_EXCL が「既存 path なら失敗」を保証するため、新規作成の一意性はこれで足りる。
1561
+ // O_NOFOLLOW は撤去した(オーナー裁定 2026-08-19)。
1562
+ const fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
1610
1563
  fs.closeSync(fd);
1611
1564
  }
1612
1565
  function realCodexHome() {
@@ -1808,10 +1761,9 @@ function readClaudeOperationMarker(meta) {
1808
1761
  if (meta.kind !== "claude")
1809
1762
  return null;
1810
1763
  const file = agentClaudeOperationPath(meta.aiterm_session, meta.launch_id);
1811
- const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1812
1764
  let fd;
1813
1765
  try {
1814
- fd = fs.openSync(file, fs.constants.O_RDONLY | nofollow);
1766
+ fd = fs.openSync(file, fs.constants.O_RDONLY);
1815
1767
  }
1816
1768
  catch (error) {
1817
1769
  if (error.code === "ENOENT")
@@ -1819,9 +1771,10 @@ function readClaudeOperationMarker(meta) {
1819
1771
  throw new AitermError(`Claude operation markerを確認できません: ${error.message}`, 2);
1820
1772
  }
1821
1773
  try {
1774
+ // parse する入力の上限だけ残す(owner・link 数・mode・O_NOFOLLOW の検査は撤去した)。
1822
1775
  const st = fs.fstatSync(fd);
1823
- if (!st.isFile() || st.uid !== currentUid() || st.nlink !== 1 || (st.mode & 0o077) !== 0 || st.size > 1024) {
1824
- throw new AitermError("Claude operation markerの安全検証に失敗しました", 2);
1776
+ if (st.size > 1024) {
1777
+ throw new AitermError("Claude operation markerが大きすぎます", 2);
1825
1778
  }
1826
1779
  let value;
1827
1780
  try {
@@ -1855,21 +1808,13 @@ function reserveClaudeOperation(meta, operationId) {
1855
1808
  }
1856
1809
  const receipt = agentClaudeDispatchReceiptPath(meta.aiterm_session, meta.launch_id, validated);
1857
1810
  try {
1858
- createEmpty0600NoFollow(receipt);
1811
+ createEmpty0600(receipt);
1859
1812
  }
1860
1813
  catch (error) {
1861
1814
  if (error.code !== "EEXIST")
1862
1815
  throw error;
1863
- let st;
1864
- try {
1865
- st = fs.lstatSync(receipt);
1866
- }
1867
- catch {
1868
- throw new AitermError("Claude dispatch receiptを確認できません", 2);
1869
- }
1870
- if (!st.isFile() || st.isSymbolicLink() || st.uid !== currentUid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
1871
- throw new AitermError("Claude dispatch receiptの安全検証に失敗しました", 2);
1872
- }
1816
+ // EEXIST 自体が「この operation は既に dispatch 済み」を意味する。
1817
+ // receipt の owner・link 数・mode・symlink 検査は撤去した(オーナー裁定 2026-08-19)。
1873
1818
  throw new AitermError(`operation ${validated} は既にdispatch済みです。再送しません。`, 2);
1874
1819
  }
1875
1820
  try {
@@ -2035,24 +1980,16 @@ export function runClaudeApproval({ action, session_id: name, operation_id: oper
2035
1980
  }
2036
1981
  function hasClaudeDispatchReceipt(meta, operationId) {
2037
1982
  const file = agentClaudeDispatchReceiptPath(meta.aiterm_session, meta.launch_id, operationId);
2038
- let st;
2039
1983
  try {
2040
- st = fs.lstatSync(file);
1984
+ // receipt は createEmpty0600 が作る空ファイル。存在=dispatch 済みで足りる。
1985
+ // owner・link 数・mode・symlink の検査は撤去した(オーナー裁定 2026-08-19)。
1986
+ return fs.statSync(file).isFile();
2041
1987
  }
2042
1988
  catch (error) {
2043
1989
  if (error.code === "ENOENT")
2044
1990
  return false;
2045
1991
  throw new AitermError(`Claude dispatch receiptを確認できません: ${error.message}`, 2);
2046
1992
  }
2047
- if (!st.isFile() ||
2048
- st.isSymbolicLink() ||
2049
- st.uid !== currentUid() ||
2050
- st.nlink !== 1 ||
2051
- (st.mode & 0o077) !== 0 ||
2052
- st.size !== 0) {
2053
- throw new AitermError("Claude dispatch receiptの安全検証に失敗しました", 2);
2054
- }
2055
- return true;
2056
1993
  }
2057
1994
  function normalizeInitialPromptState(v) {
2058
1995
  if (v === true)
@@ -2240,8 +2177,8 @@ function createClaudeAgentMetadata(name, cwd, initialPrompt, launchOperationId,
2240
2177
  const launchId = randomBytes(16).toString("hex");
2241
2178
  const eventFile = agentEventPath(name, launchId);
2242
2179
  const resultFile = agentClaudeResultPath(name, launchId);
2243
- createEmpty0600NoFollow(eventFile);
2244
- createEmpty0600NoFollow(resultFile);
2180
+ createEmpty0600(eventFile);
2181
+ createEmpty0600(resultFile);
2245
2182
  const claudeSettings = createClaudeCorrelationSettings(name, launchId);
2246
2183
  const meta = {
2247
2184
  kind: "claude",
@@ -2266,7 +2203,7 @@ function createClaudeAgentMetadata(name, cwd, initialPrompt, launchOperationId,
2266
2203
  function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}, writeScope, lineageContext) {
2267
2204
  const launchId = randomBytes(16).toString("hex");
2268
2205
  const eventFile = agentEventPath(name, launchId);
2269
- createEmpty0600NoFollow(eventFile);
2206
+ createEmpty0600(eventFile);
2270
2207
  const codexHome = realCodexHome();
2271
2208
  const meta = {
2272
2209
  kind: "codex",
@@ -2290,7 +2227,7 @@ function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}, writ
2290
2227
  function createGrokAgentMetadata(kind, name, cwd, initialPrompt, authPath, writeScope, lineageContext) {
2291
2228
  const launchId = randomBytes(16).toString("hex");
2292
2229
  const eventFile = agentEventPath(name, launchId);
2293
- createEmpty0600NoFollow(eventFile);
2230
+ createEmpty0600(eventFile);
2294
2231
  const grokHome = realGrokHome();
2295
2232
  const meta = {
2296
2233
  kind,
@@ -2979,7 +2916,7 @@ function readClaudeResultText(meta, done, operationId) {
2979
2916
  st.isSymbolicLink() ||
2980
2917
  st.uid !== currentUid() ||
2981
2918
  st.nlink !== 1 ||
2982
- (st.mode & 0o077) !== 0 ||
2919
+ (!isWin && (st.mode & 0o077) !== 0) ||
2983
2920
  st.size > CLAUDE_RESULT_MAX_BYTES + 4096) {
2984
2921
  throw new AitermError("Claude result file の安全検証に失敗しました", 2);
2985
2922
  }
@@ -4104,35 +4041,22 @@ function isWindowsDrivePath(candidate) {
4104
4041
  function isWindowsNativeExecutable(candidate) {
4105
4042
  return isWindowsDrivePath(candidate) && /\.(?:exe|com|cmd|bat)$/i.test(candidate);
4106
4043
  }
4107
- function isUsableWslExecutable(candidate) {
4108
- if (!isWin)
4109
- return false;
4110
- let wslPath = candidate;
4111
- if (isWindowsDrivePath(candidate)) {
4112
- try {
4113
- if (!fs.statSync(candidate).isFile())
4114
- return false;
4115
- wslPath = toWslPath(candidate);
4116
- }
4117
- catch {
4118
- return false;
4119
- }
4120
- }
4121
- else if (!candidate.startsWith("/")) {
4122
- return false;
4123
- }
4124
- const checked = spawnSync("wsl.exe", ["-e", "test", "-f", wslPath, "-a", "-x", wslPath], {
4125
- encoding: "utf8",
4126
- timeout: 5000,
4127
- });
4128
- return checked.status === 0;
4129
- }
4044
+ // Windows の bin 受入: native 実行ファイル(.exe/.cmd/.bat)に加え、pane shell
4045
+ // (Git Bash)が shebang で実行できる script も実在すれば受け入れる。旧 WSL 側
4046
+ // バイナリ検査への黙ったフォールバックは廃止(別 HOME・別 auth の subagent を
4047
+ // 作るため)。native 実行ファイルの強制が要る vendor(grok/composer の実効
4048
+ // sandbox 等)は openAgent 側の専用ゲートが明示エラーで担う。
4130
4049
  function isUsableAgentExecutableFile(candidate) {
4131
4050
  if (!isWin)
4132
4051
  return isUsableExecutableFile(candidate);
4133
4052
  if (isWindowsNativeExecutable(candidate))
4134
4053
  return isUsableExecutableFile(candidate);
4135
- return isUsableWslExecutable(candidate);
4054
+ try {
4055
+ return fs.statSync(candidate).isFile();
4056
+ }
4057
+ catch {
4058
+ return false;
4059
+ }
4136
4060
  }
4137
4061
  function resolveWindowsCodexShim(kind, candidate) {
4138
4062
  if (!isWin || kind !== "codex" || !/\.(?:cmd|bat)$/i.test(candidate))
@@ -4154,14 +4078,26 @@ function resolveWindowsCodexShim(kind, candidate) {
4154
4078
  throw new AitermError(`CODEX_BIN のnpm shimからWindows native codex.exeを解決できません: ${candidate}。` +
4155
4079
  "@openai/codexを再インストールするか、CODEX_BINへcodex.exeを指定してください", 2);
4156
4080
  }
4157
- function agentBinForWslShell(bin) {
4158
- return isWin && isWindowsDrivePath(bin) ? toWslPath(bin) : bin;
4081
+ // pane 内の POSIX shell(Windows は Git Bash 等)へ渡す bin パス。Git Bash は
4082
+ // バックスラッシュをエスケープとして解釈しうるため、ドライブパスは forward slash 形へ。
4083
+ function agentBinForPaneShell(bin) {
4084
+ return isWin && isWindowsDrivePath(bin) ? bin.replace(/\\/g, "/") : bin;
4159
4085
  }
4160
- function spawnAgentControlCommand(bin, args, cwd, options) {
4161
- if (!isWin || isWindowsNativeExecutable(bin))
4162
- return spawnSync(bin, args, options);
4163
- const wslCwd = isWindowsDrivePath(cwd) ? toWslPath(cwd) : cwd;
4164
- return spawnSync("wsl.exe", ["--cd", wslCwd, "-e", agentBinForWslShell(bin), ...args], options);
4086
+ function spawnAgentControlCommand(bin, args, _cwd, options) {
4087
+ // 受入(isUsableAgentExecutableFile)が「使える」と判定した bin は、control command
4088
+ //(`claude auth status --json`/`grok models`)でも同じく実行できなければならない。
4089
+ if (isWin && !/\.(?:exe|com)$/i.test(bin)) {
4090
+ if (/\.(?:cmd|bat)$/i.test(bin)) {
4091
+ // Node は CVE-2024-27980 対処以降、.cmd/.bat の直接 spawn を EINVAL で拒否する。
4092
+ // 受入が .cmd/.bat を許す以上、control 経路は shell 経由で実行する(args は固定語彙)。
4093
+ return spawnSync(bin, args, { ...options, shell: true });
4094
+ }
4095
+ // 受入は pane shell(Git Bash)が shebang で実行できる script も許す。Windows の
4096
+ // CreateProcess は shebang を解さないため、control command も同じ Git Bash で実行する。
4097
+ // これを直接 spawn すると常に失敗し、「受入が通した bin で起動が必ず失敗する」矛盾になる。
4098
+ return spawnSync(resolveWinPaneShell("bash"), [bin, ...args], options);
4099
+ }
4100
+ return spawnSync(bin, args, options);
4165
4101
  }
4166
4102
  const THROUGHLINE_HANDOFF_CONTEXT_SCHEMA = "throughline.handoff_context.v1";
4167
4103
  const PORTABLE_FORK_MISSION_SEPARATOR = "\n\n---\n\n## Portable fork mission\n\n";
@@ -4298,20 +4234,7 @@ function buildAgentCmd(kind, bin, model, effort, prompt, meta = null) {
4298
4234
  parts.push(shq(prompt)); // 初手プロンプト(任意)
4299
4235
  return parts.join(" ");
4300
4236
  }
4301
- // Windows native .exe を WSL pane から interop 起動する時だけ、生きた anchor socket と、
4302
- // 注入 env を Win32 process まで運ぶ WSLENV(/w) を先頭へ加える(WSL 側の env は interop 先の
4303
- // Windows process へ既定では渡らない・実測 2026-08-15)。POSIX と非 native bin では不変。
4304
- export function winInteropEnvTokens(tokens, interopSocket) {
4305
- if (!interopSocket)
4306
- return tokens;
4307
- const names = tokens.map((token) => token.slice(0, token.indexOf("=")));
4308
- return [
4309
- `WSL_INTEROP=${shq(interopSocket)}`,
4310
- ...(names.length ? [`WSLENV=${names.map((name) => `${name}/w`).join(":")}`] : []),
4311
- ...tokens,
4312
- ];
4313
- }
4314
- function agentEnvPrefix(meta, sid, envVars = [], interopSocket = null) {
4237
+ function agentEnvPrefix(meta, sid, envVars = []) {
4315
4238
  const inherited = envVars.flatMap((name) => {
4316
4239
  const value = process.env[name];
4317
4240
  return value === undefined ? [] : [`${name}=${shq(value)}`];
@@ -4341,8 +4264,7 @@ function agentEnvPrefix(meta, sid, envVars = [], interopSocket = null) {
4341
4264
  ...common,
4342
4265
  ];
4343
4266
  })();
4344
- const finalTokens = winInteropEnvTokens(tokens, interopSocket);
4345
- return finalTokens.length ? finalTokens.join(" ") + " " : "";
4267
+ return tokens.length ? tokens.join(" ") + " " : "";
4346
4268
  }
4347
4269
  function agentLabel(kind) {
4348
4270
  return kind === "claude"
@@ -4508,12 +4430,11 @@ export function openAgent(kind, opts = {}) {
4508
4430
  }
4509
4431
  if (kind === "claude")
4510
4432
  assertClaudeAuthenticationReady(bin);
4511
- // Windows は起動コマンドが WSL 内 bash で走る(tmux ブリッジ)。bin/cwd を /mnt/c/... 形へ変換して
4512
- // 渡す(ログの toWslPath と対称・A1)。前提: Windows 側に CLI を導入(resolveAgentBin が Windows
4513
- // パスで解決)。toWslPath は session を作る前に呼ぶ=変換失敗(非ドライブパス)で残骸 session を残さない。
4433
+ // Windows の起動コマンドは native psmux pane の Git Bash で走る(WSL 橋・/mnt/c 変換は
4434
+ // e3f5fc8 で全廃。bin/cwd は Windows パスの forward slash 形のまま渡す)。
4514
4435
  // 未検証リスク: npm グローバル導入の codex.cmd/.bat シムの対話 TUI 描画は実 Windows でしか確認
4515
- // できない(CI 非対象。docs/03_audit-sweep-2026-07.md 参照)。native .exe の interop 起動と
4516
- // TUI 描画は grok.exe で実測済み(2026-08-15)。
4436
+ // できない(CI 非対象。docs/03_audit-sweep-2026-07.md 参照)。native .exe の TUI 描画は
4437
+ // grok.exe で実測済み(2026-08-15)。
4517
4438
  // Windows の grok/composer は Windows native の grok.exe だけを起動する(オーナー裁定 2026-08-15:
4518
4439
  // WindowsネイティブはWindowsネイティブで完結させ、WSL2へ持ち込まない)。WSL 側 grok を起動すると
4519
4440
  // vendor 実体が WSL process になり、auth・session 記録(events/chat_history)が WSL home 側へ分裂して
@@ -4522,11 +4443,10 @@ export function openAgent(kind, opts = {}) {
4522
4443
  ownTelemetryFailure("AITERM.VENDOR_LAUNCHER_FAILED", new AitermError(`Windows の ${label} launcher は Windows native の grok.exe だけを起動できます(現在の解決先: ${bin})。` +
4523
4444
  "Windows 版 Grok CLI を導入するか、GROK_BIN に grok.exe の絶対パスを指定してください。", 2), 2);
4524
4445
  }
4525
- const binForCmd = agentBinForWslShell(bin);
4526
- const cwdForCmd = cwd && isWin ? toWslPath(cwd) : cwd;
4527
- // native .exe を pane 内で interop 起動するには生きた WSL_INTEROP が必須(anchor 解説参照)。
4528
- // session 作成前に確保し、失敗時は残骸 session を残さない。
4529
- const interopSocket = isWin && isWindowsNativeExecutable(bin) ? ensureWinInteropAnchor() : null;
4446
+ const binForCmd = agentBinForPaneShell(bin);
4447
+ // Windows native pane(psmux + Git Bash)は Windows パスをそのまま扱える。
4448
+ // Git Bash の cd はドライブパスを forward slash 形で受けるのが安全。
4449
+ const cwdForCmd = cwd && isWin ? cwd.replace(/\\/g, "/") : cwd;
4530
4450
  const grokAuthPath = agentDone && (kind === "grok" || kind === "composer") ? resolveAndValidateGrokAuth(realGrokHome()) : null;
4531
4451
  if (kind === "grok" || kind === "composer") {
4532
4452
  const requestedModel = model ?? (kind === "composer" ? GROK_MODEL_DEFAULTS.composer : null);
@@ -4559,7 +4479,7 @@ export function openAgent(kind, opts = {}) {
4559
4479
  agentMetadataNegativeCache.delete(sid);
4560
4480
  launchNote = buildAgentLaunchNote(kind, model, effort, meta);
4561
4481
  const cmd = buildAgentCmd(kind, binForCmd, model, effort, opts.prompt ?? null, meta);
4562
- const envPrefix = agentEnvPrefix(meta, sid, envVars, interopSocket);
4482
+ const envPrefix = agentEnvPrefix(meta, sid, envVars);
4563
4483
  const full = cwdForCmd ? `cd ${shq(cwdForCmd)} && ${envPrefix}${cmd}` : `${envPrefix}${cmd}`;
4564
4484
  // force:true で送る。起動骨格は `bin '...'` の固定形で、prompt/cwd/effort は shq でクオート済みの
4565
4485
  // 引数=シェルは決して破壊コマンドとして実行しない。破壊ゲート(生シェルコマンド想定)を prompt に
@@ -19,8 +19,13 @@ function hasAitermEnv() {
19
19
  process.env.AITERM_AGENT_LAUNCH_ID);
20
20
  }
21
21
  function uid() {
22
+ // Windows(native) は process.getuid を持たない。core の currentUid()・claude-stop-hook と
23
+ // 同じ受容として 0 を返す(Windows の fs.Stats.uid は常に 0 のため owner 比較は自然に
24
+ // 通過する。NTFS ACL は別体系)。POSIX は getuid のまま=挙動不変。
25
+ // 以前はここで fail していたため、Windows では Grok/Composer の完了 event が
26
+ // 一度も書かれず aiterm-wait が timeout まで返らなかった。
22
27
  if (typeof process.getuid !== "function")
23
- fail("POSIX getuid が使えません");
28
+ return 0;
24
29
  return process.getuid();
25
30
  }
26
31
  function runtimeStateBase() {
@@ -36,17 +41,11 @@ function runtimeStateBase() {
36
41
  }
37
42
  return os.tmpdir();
38
43
  }
39
- function secureAgentsDir() {
44
+ function agentsDir() {
40
45
  const root = path.join(runtimeStateBase(), `aiterm-mcp-${uid()}`);
41
46
  const agents = path.join(root, "agents");
42
- const rst = fs.lstatSync(root);
43
- if (!rst.isDirectory() || rst.isSymbolicLink() || rst.uid !== uid() || (rst.mode & 0o077) !== 0) {
44
- fail(`agent state root が安全ではありません: ${root}`);
45
- }
46
- const ast = fs.lstatSync(agents);
47
- if (!ast.isDirectory() || ast.isSymbolicLink() || ast.uid !== uid() || (ast.mode & 0o077) !== 0) {
48
- fail(`agent state dir が安全ではありません: ${agents}`);
49
- }
47
+ // per-user runtime dir 前提のため、symlink・owner・link 数・mode の検査は撤去した
48
+ // (共有 /tmp に敵対的同居主体がいる前提の防御。オーナー裁定 2026-08-19)。
50
49
  return agents;
51
50
  }
52
51
  function str(v) {
@@ -65,16 +64,13 @@ async function readStdin() {
65
64
  return Buffer.concat(chunks).toString("utf8");
66
65
  }
67
66
  function appendEvent(file, event) {
68
- const nofollow = fs.constants.O_NOFOLLOW ?? 0;
69
67
  const line = JSON.stringify(event) + "\n";
70
68
  if (Buffer.byteLength(line, "utf8") > 64 * 1024)
71
69
  fail("event line が大きすぎます");
72
- const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_WRONLY | nofollow, 0o600);
70
+ const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_WRONLY, 0o600);
73
71
  try {
74
72
  const st = fs.fstatSync(fd);
75
- if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
76
- fail(`event file が安全ではありません: ${file}`);
77
- }
73
+ // st は短書き込み時の巻き戻し(ftruncate)に使う。安全性検査としては使わない。
78
74
  const written = fs.writeSync(fd, line, undefined, "utf8");
79
75
  if (written < Buffer.byteLength(line, "utf8")) {
80
76
  fs.ftruncateSync(fd, st.size);
@@ -107,7 +103,7 @@ async function main() {
107
103
  payload = {};
108
104
  }
109
105
  }
110
- const agents = secureAgentsDir();
106
+ const agents = agentsDir();
111
107
  const eventFile = path.join(agents, `${session}.${launchId}.events.jsonl`);
112
108
  appendEvent(eventFile, {
113
109
  type: "agent_done",
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * aiterm-mcp — AI が握るローカル永続端末を stdio MCP サーバとして公開する(Node/TS 版)。
4
4
  *
5
- * WSL2/Linux/mac のローカルで動かし、握るのはローカル端末1個。リモートは pty_send "ssh ..." で
6
- * 中に入る(ネスト)。バックエンドは tmux(実行時の前提)。ロジックは core.ts に集約。
5
+ * Linux/WSL2/mac/Windows native のローカルで動かし、握るのはローカル端末1個。リモートは
6
+ * pty_send "ssh ..." で中に入る(ネスト)。バックエンドは tmux(Windows native は tmux CLI
7
+ * 互換の psmux)。ロジックは core.ts に集約。
7
8
  *
8
9
  * 重要: stdio MCP は stdout が JSON-RPC 専用。診断は stderr/console.error のみ(console.log 禁止)。
9
10
  * 起動: npx -y aiterm-mcp(または mcp 登録のコマンドに同じ)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "mcpName": "io.github.kitepon/aiterm-mcp",
5
5
  "description": "Persistent tmux terminal MCP for launching and driving Claude, Codex, Grok, or Composer from any MCP client, cross-vendor or same-vendor. Also runs durable PTY sessions for SSH, containers, and REPLs.",
6
6
  "keywords": [