aiterm-mcp 0.59.0 → 0.60.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.
@@ -0,0 +1,610 @@
1
+ // 差し込みを追う口と「席の今の番」(ADR 0122)。BellTeamのためにある。
2
+ // BellTeamは、席への送信が「動いている番への差し込み」で返った時、その番を自分で始めていないと、終わりも答えも知る道が無かった
3
+ // (実被弾 2026-10-13: Aitermの子の結果で始まったCodexの番へ指揮官氏の文が3通差し込まれ、席は答えたのに会話画面に何も出なかった)。
4
+ // ここが持つのは3つ。
5
+ // 1. 差し込みの控え: 打つ直前の位置と、文の頭。待ちが、harnessの記録の中からその文を見つけるために読む。
6
+ // 2. 差し込みを追う待ち: その文を取った番の終わりを返す。取り込まれたか、次の番になったか、会話へ入らなかったかを分けて返す。
7
+ // 3. 席の今の番: 記録の上で動いている番の位置(Codex)。画面は読まない。
8
+ // 利用者向けのツール説明と引数には出さない(合間の言葉と同じ。interim-words.ts の冒頭)。要らないものと見て消さないこと。
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { createHash, randomBytes } from "node:crypto";
12
+ import { AitermError } from "./errors.js";
13
+ import { AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, AGENT_RATE_LIMIT_POLL_MS, AGENT_TURN_BOUNDARY_TAIL_BYTES, agentHarness, agentMetadataPath, agentsDir, assertSessionName, LAUNCH_ID_RE, readFileRange, safeStatSize, scanLinesNewestFirst, sleep, } from "./agent-shared.js";
14
+ import { claudeApiErrorFromLine, claudeSessionTranscriptPath } from "./harnesses/claude.js";
15
+ import { codexBoundTranscript, codexCompletionEvent, codexRolloutSessionId, codexRootTranscript, codexRunningTurn, codexTranscriptSessionId, codexTurnError, codexTurnErrorLine, codexUsageLimit, } from "./harnesses/codex.js";
16
+ import { interimFromToken, parseInterimFrom } from "./interim-words.js";
17
+ /** 差し込みの返りの_metaで返すキー。 */
18
+ export const STEER_RESULT_META_KEY = "aiterm/steer";
19
+ /** pty_listの結果の_metaで返すキー。 */
20
+ export const SEAT_TURNS_RESULT_META_KEY = "aiterm/seat_turns";
21
+ // ---------------------------------------------------------------- 差し込みの控え
22
+ const STEER_ID_RE = /^[0-9a-f]{16}$/;
23
+ // 控えは追記する。大きくなったら、新しい方だけを残して書き直す(待ちが読むのは、打った直後から番の終わりまでの間)。
24
+ const STEER_LEDGER_MAX_BYTES = 256 * 1024;
25
+ const STEER_LEDGER_KEEP = 100;
26
+ // 文の見分けに使うのは、空白を除いた先頭。記録の側は、harnessが前後に何かを足しても、この並びを含む。
27
+ const STEER_NEEDLE_CHARS = 160;
28
+ // これより短い文は、ほかの入力と取り違えるので見分けに使わない。
29
+ const STEER_NEEDLE_MIN_CHARS = 4;
30
+ // 画像を付けた文の後ろに足す行(core.attachImages)。harnessが書き換える所なので、見分けに使わない。
31
+ const ATTACHED_IMAGE_BLOCK = "\n\n[aiterm 添付画像 ";
32
+ export function steerLedgerPath(name, launchId) {
33
+ assertSessionName(name);
34
+ if (!LAUNCH_ID_RE.test(launchId))
35
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
36
+ return path.join(agentsDir(), `${name}.${launchId}.steers.jsonl`);
37
+ }
38
+ const squeeze = (text) => text.replace(/\s+/gu, "");
39
+ export function steerNeedle(text) {
40
+ const cut = text.indexOf(ATTACHED_IMAGE_BLOCK);
41
+ const needle = squeeze(cut < 0 ? text : text.slice(0, cut)).slice(0, STEER_NEEDLE_CHARS);
42
+ return needle.length >= STEER_NEEDLE_MIN_CHARS ? needle : "";
43
+ }
44
+ function parseSteerEntry(line) {
45
+ if (!line.trim())
46
+ return null;
47
+ let value;
48
+ try {
49
+ value = JSON.parse(line);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (value?.schema !== "aiterm.steer-entry.v1" || typeof value.steer_id !== "string" || !STEER_ID_RE.test(value.steer_id)
55
+ || typeof value.at !== "string" || !Number.isSafeInteger(value.event_cursor) || value.event_cursor < 0
56
+ || parseInterimFrom(value.interim_from) === null || typeof value.needle !== "string")
57
+ return null;
58
+ return value;
59
+ }
60
+ /**
61
+ * 差し込みを打つ直前に、位置と文の頭を控える。書けなかった時はnull(差し込みは止めない。呼び手は待つ手がかりを受け取らないだけ)。
62
+ */
63
+ export function recordSteer(meta, from, body) {
64
+ try {
65
+ const file = steerLedgerPath(meta.aiterm_session, meta.launch_id);
66
+ const entry = {
67
+ schema: "aiterm.steer-entry.v1",
68
+ steer_id: randomBytes(8).toString("hex"),
69
+ at: new Date().toISOString(),
70
+ event_cursor: from.event_cursor,
71
+ interim_from: interimFromToken(from),
72
+ needle: steerNeedle(body),
73
+ };
74
+ if (safeStatSize(file) > STEER_LEDGER_MAX_BYTES) {
75
+ const kept = fs.readFileSync(file, "utf8").split("\n").filter(line => parseSteerEntry(line)).slice(-STEER_LEDGER_KEEP);
76
+ const temporary = `${file}.${process.pid}.tmp`;
77
+ fs.writeFileSync(temporary, kept.map(line => `${line}\n`).join(""), { mode: 0o600 });
78
+ fs.renameSync(temporary, file);
79
+ }
80
+ fs.appendFileSync(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
81
+ return entry;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ export function readSteerEntry(meta, steerId) {
88
+ if (!STEER_ID_RE.test(steerId))
89
+ throw new AitermError("steer の値が不正です", 2);
90
+ let body;
91
+ try {
92
+ body = fs.readFileSync(steerLedgerPath(meta.aiterm_session, meta.launch_id), "utf8");
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ const lines = body.split("\n");
98
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
99
+ const entry = parseSteerEntry(lines[index]);
100
+ if (entry?.steer_id === steerId)
101
+ return entry;
102
+ }
103
+ return null;
104
+ }
105
+ // ---------------------------------------------------------------- 差し込みを追う待ち
106
+ // 番が終わった後、その文が次の番を始めるのを待つ間。harnessは番の終わりの直後に待ち行列の文を動かす(実物で0.1秒ほど)。
107
+ const STEER_AFTER_TURN_GRACE_MS = 8_000;
108
+ // 打ってから、会話の記録に何も現れないまま「入っていない」と決めるまでの間。Claude Codeは打った時点で待ち行列の行を書く。
109
+ const STEER_NOT_TAKEN_GRACE_MS = 30_000;
110
+ // 動いている番が在るかを記録で見直す間隔(Codex。番の開始の印までさかのぼって読む)。
111
+ const STEER_RUNNING_CHECK_MS = 5_000;
112
+ // 1回の見回りで読む上限。長い番の途中から待ち始めても、まとめて読まない。
113
+ const STEER_READ_CHUNK_BYTES = 4 * 1024 * 1024;
114
+ // Stopの知らせの時刻は、最後の文の行の時刻の少し後(実物で50〜70ミリ秒)。時計の刻みの分だけ許す。
115
+ const CLAUDE_STOP_AFTER_ANSWER_SLACK_MS = 250;
116
+ const fate = (placement, takenAt = null) => ({ placement, taken_at: takenAt });
117
+ /** 記録の増えた分を、行ごとに読む。行の途中で終わった分は次の回へ持ち越す。 */
118
+ class LineFollower {
119
+ file;
120
+ offset;
121
+ carry = Buffer.alloc(0);
122
+ dropFirst;
123
+ constructor(file, offset) {
124
+ this.file = file;
125
+ this.offset = offset;
126
+ // 読み始めが行の途中なら、最初の切れ端は読まない。
127
+ this.dropFirst = offset > 0 && readFileRange(file, offset - 1, offset).toString("utf8") !== "\n";
128
+ }
129
+ /** 読める所まで読んで、そろった行を返す。fileが短くなった時はnull。 */
130
+ read() {
131
+ const size = safeStatSize(this.file);
132
+ if (size < this.offset)
133
+ return null;
134
+ if (size === this.offset)
135
+ return [];
136
+ const end = Math.min(size, this.offset + STEER_READ_CHUNK_BYTES);
137
+ const chunk = readFileRange(this.file, this.offset, end);
138
+ this.offset += chunk.length;
139
+ const joined = this.carry.length ? Buffer.concat([this.carry, chunk]) : chunk;
140
+ const last = joined.lastIndexOf(0x0a);
141
+ if (last < 0) {
142
+ this.carry = joined;
143
+ return [];
144
+ }
145
+ this.carry = joined.subarray(last + 1);
146
+ const lines = joined.subarray(0, last).toString("utf8").split("\n");
147
+ if (this.dropFirst) {
148
+ lines.shift();
149
+ this.dropFirst = false;
150
+ }
151
+ return lines;
152
+ }
153
+ /** まだ読んでいない分が在るか。 */
154
+ get behind() { return safeStatSize(this.file) > this.offset; }
155
+ }
156
+ function baseObservation(meta) {
157
+ return {
158
+ schema: "aiterm.agent-wait-result.v1",
159
+ session_id: meta.aiterm_session,
160
+ launch_id: meta.launch_id,
161
+ vendor: meta.kind,
162
+ harness: agentHarness(meta.kind),
163
+ operation_id: null,
164
+ vendor_session_id: meta.vendor_session_id ?? null,
165
+ turn_id: null,
166
+ malformed_events: 0,
167
+ at: null,
168
+ rate_limit: null,
169
+ error: null,
170
+ error_kind: null,
171
+ };
172
+ }
173
+ /**
174
+ * 差し込んだ文を取った番の終わりを待つ。
175
+ * CodexとClaude Codeは、harnessの記録の中から文を見つけて、その番の終わりを返す。
176
+ * GrokとCursorは「今すぐ送る」で必ず動いている番へ入る(入らなければ差し込みが誤りで返る)ので、打った後の最初の完了が、その番の終わり。
177
+ */
178
+ export async function observeSteerDone(meta, entry, timeoutSeconds, deps) {
179
+ if (meta.kind === "codex")
180
+ return observeCodexSteerDone(meta, entry, timeoutSeconds, deps);
181
+ if (meta.kind === "claude")
182
+ return observeClaudeSteerDone(meta, entry, timeoutSeconds, deps);
183
+ return { ...await deps.plain(entry.event_cursor, timeoutSeconds), steer: fate("same_turn") };
184
+ }
185
+ // ------------------------------------------------ Codex
186
+ /**
187
+ * Codexの記録の、利用者の文の行。文でなければnull。
188
+ * 番の頭に入る前提の文(AGENTS.md・環境)も、userの行として書かれる。行の種類が書いてある版(0.162.1の実記録では
189
+ * 利用者の入力が`user.text`、前提の文が`agents_md.instructions`など)では、利用者の入力だけを数える。
190
+ */
191
+ function codexUserText(record) {
192
+ const payload = record?.payload;
193
+ if (record?.type === "response_item" && payload?.type === "message" && payload?.role === "user" && Array.isArray(payload.content)) {
194
+ const kinds = payload.internal_chat_message_metadata_passthrough?.content_item_kinds;
195
+ if (Array.isArray(kinds) && !kinds.some((kind) => typeof kind === "string" && kind.startsWith("user.")))
196
+ return null;
197
+ return payload.content.map((item) => (typeof item?.text === "string" ? item.text : "")).join("");
198
+ }
199
+ if (record?.type === "event_msg" && payload?.type === "user_message" && typeof payload.message === "string")
200
+ return payload.message;
201
+ return null;
202
+ }
203
+ /**
204
+ * Codex: 文は、取られた時に、その番のturn_idを付けたuserの行として記録へ書かれる(0.162.1の実記録 2026-10-13。打って約1秒)。
205
+ * その行のturn_idのtask_completeが、文を取った番の終わり。番の開始の印が打った位置より後に在れば、文が新しい番を始めている。
206
+ */
207
+ async function observeCodexSteerDone(meta, entry, timeoutSeconds, deps) {
208
+ const now = deps.now ?? (() => Date.now());
209
+ const anchored = () => {
210
+ try {
211
+ return meta.vendor_session_id ? codexBoundTranscript(meta) : codexRootTranscript(meta);
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ };
217
+ const transcript = anchored();
218
+ if (!transcript)
219
+ return { ...await deps.plain(entry.event_cursor, timeoutSeconds), steer: fate("unknown") };
220
+ const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
221
+ const harnessSessionId = meta.vendor_session_id ? codexRolloutSessionId(meta, transcript) : codexTranscriptSessionId(transcript);
222
+ const follower = new LineFollower(transcript, entry.event_cursor);
223
+ const sentAt = Date.parse(entry.at);
224
+ const deadline = performance.now() + timeoutSeconds * 1000;
225
+ let malformedEvents = 0;
226
+ let sawUser = false;
227
+ let openTurn = null;
228
+ const startedAfter = new Set();
229
+ let taken = null;
230
+ // 文を見つける前に終わった、最初の番(見分けられなかった時に返す)。
231
+ let first = null;
232
+ let runningCheckAt = 0;
233
+ let quietSince = performance.now();
234
+ const finish = (done, record, steer) => {
235
+ const observed = { ...baseObservation(meta), vendor_session_id: done.vendor_session_id ?? meta.vendor_session_id ?? null,
236
+ turn_id: done.turn_id, malformed_events: malformedEvents, at: done.at, steer };
237
+ const limited = codexUsageLimit(record);
238
+ if (limited)
239
+ return { ...observed, outcome: "rate_limited", rate_limit: limited };
240
+ const failed = codexTurnError(record);
241
+ return failed ? { ...observed, outcome: "error", error: codexTurnErrorLine(failed), error_kind: failed.info } : { ...observed, outcome: "done" };
242
+ };
243
+ const pending = (outcome) => ({ ...baseObservation(meta), outcome, malformed_events: malformedEvents, steer: taken?.fate ?? fate("unknown") });
244
+ for (;;) {
245
+ deps.signal?.throwIfAborted();
246
+ if (!fs.existsSync(metadataFile))
247
+ return pending("closed");
248
+ const lines = follower.read();
249
+ if (lines === null)
250
+ throw new AitermError("Codex transcript が完了待機中に短くなりました。該当セッションを閉じて起動し直してください。", 2);
251
+ if (lines.length)
252
+ quietSince = performance.now();
253
+ for (const line of lines) {
254
+ if (!line.trim())
255
+ continue;
256
+ if (Buffer.byteLength(line, "utf8") > AGENT_EVENT_MAX_BYTES) {
257
+ malformedEvents++;
258
+ continue;
259
+ }
260
+ let record;
261
+ try {
262
+ record = JSON.parse(line);
263
+ }
264
+ catch {
265
+ malformedEvents++;
266
+ continue;
267
+ }
268
+ const payload = record?.payload;
269
+ if (record?.type === "event_msg" && payload?.type === "task_started" && typeof payload.turn_id === "string") {
270
+ openTurn = payload.turn_id;
271
+ startedAfter.add(payload.turn_id);
272
+ continue;
273
+ }
274
+ const text = codexUserText(record);
275
+ if (text !== null) {
276
+ sawUser = true;
277
+ if (!taken && entry.needle && squeeze(text).includes(entry.needle)) {
278
+ const tagged = payload?.internal_chat_message_metadata_passthrough?.turn_id;
279
+ const turn = typeof tagged === "string" && tagged ? tagged : openTurn;
280
+ taken = { turn, fate: fate(turn !== null && startedAfter.has(turn) ? "next_turn" : "same_turn", typeof record.timestamp === "string" ? record.timestamp : null) };
281
+ }
282
+ continue;
283
+ }
284
+ const done = codexCompletionEvent(meta, harnessSessionId, record);
285
+ if (!done)
286
+ continue;
287
+ if (openTurn === done.turn_id)
288
+ openTurn = null;
289
+ // 文の行にturn_idが無い版では、行が書かれた時に動いていた番(その後の最初の完了)を、文を取った番と数える。
290
+ if (taken && (taken.turn === null || taken.turn === done.turn_id))
291
+ return finish(done, record, taken.fate);
292
+ if (!taken && !first) {
293
+ const stamped = Date.parse(record.timestamp);
294
+ first = { done, record, seenAt: Number.isFinite(stamped) ? stamped : now() };
295
+ }
296
+ }
297
+ if (!taken && !follower.behind) {
298
+ if (first) {
299
+ // 番は終わったのに、文が次の番を始めない。ほかの利用者の行が在れば、見分けを誤ったかも知れないので、今までの意味(最初の番の終わり)で返す。
300
+ if (now() - first.seenAt >= STEER_AFTER_TURN_GRACE_MS) {
301
+ return sawUser ? finish(first.done, first.record, fate("unknown")) : pending("not_taken");
302
+ }
303
+ }
304
+ else if (!sawUser && Number.isFinite(sentAt) && now() - sentAt >= STEER_NOT_TAKEN_GRACE_MS && performance.now() >= runningCheckAt) {
305
+ runningCheckAt = performance.now() + STEER_RUNNING_CHECK_MS;
306
+ // 打った後に利用者の行も番の終わりも無く、動いている番も無い。文は会話へ入っていない。
307
+ if (codexRunningTurn(meta) === null)
308
+ return pending("not_taken");
309
+ }
310
+ // 待っている間に、同じprocessの新しい会話へ移っていた時(/new など。ADR 0105)は、文の行をこの記録では追えない。今までの待ちへ渡す。
311
+ if (performance.now() - quietSince >= STEER_RUNNING_CHECK_MS) {
312
+ quietSince = performance.now();
313
+ let current = null;
314
+ try {
315
+ current = codexRootTranscript(meta);
316
+ }
317
+ catch { /* 読めない時は、今の記録を見続ける */ }
318
+ if (current && current !== transcript) {
319
+ const remaining = Math.max(0, Math.ceil((deadline - performance.now()) / 1000));
320
+ return { ...await deps.plain(entry.event_cursor, remaining), steer: fate("unknown") };
321
+ }
322
+ }
323
+ }
324
+ if (performance.now() >= deadline)
325
+ return pending(timeoutSeconds === 0 ? "running" : "timeout");
326
+ await sleep(AGENT_DONE_POLL_MS);
327
+ }
328
+ }
329
+ // ------------------------------------------------ Claude Code
330
+ function claudeTextBlocks(content) {
331
+ if (typeof content === "string")
332
+ return content;
333
+ if (!Array.isArray(content))
334
+ return "";
335
+ return content.map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : "")).join("");
336
+ }
337
+ /** 番を始めた利用者の文の行(道具の結果の行、子の行、圧縮の要約は含めない)。文でなければnull。 */
338
+ function claudePromptText(record) {
339
+ if (record?.type !== "user" || record.isSidechain === true || record.isCompactSummary === true || record.isMeta === true)
340
+ return null;
341
+ const content = record.message?.content;
342
+ if (typeof content === "string")
343
+ return content;
344
+ if (!Array.isArray(content) || content.some((part) => part?.type === "tool_result"))
345
+ return null;
346
+ const text = claudeTextBlocks(content);
347
+ return text ? text : null;
348
+ }
349
+ /**
350
+ * Claude Code: 番の最中に打った文は、待ち行列へ入る(queue-operationのenqueue。contentに文の全文)。
351
+ * 道具の合間に取り込まれるとremove(reason: absorbed_mid_turn。contentは同じ)、取り込まれずに番が終わるとdequeueの後にuserの行になる。
352
+ * 入力待ちの席へ打った文は、待ち行列を通らずにuserの行になる(2.1.296の実記録 2026-10-13)。
353
+ * その番の終わりは、文が取られた行より後に書かれた答えを持つ完了。Stopのhookが書く知らせのresult_digestは、最後の文
354
+ * (stop_reasonがend_turnの行のtext)のsha256と同じ(実物で3席)。時刻の近さで選ぶと、前の番のStop(次の番の開始と0.1秒しか
355
+ * 離れない)を取り違える。
356
+ */
357
+ async function observeClaudeSteerDone(meta, entry, timeoutSeconds, deps) {
358
+ const now = deps.now ?? (() => Date.now());
359
+ const from = parseInterimFrom(entry.interim_from);
360
+ const transcript = claudeSessionTranscriptPath(meta);
361
+ if (!transcript || !from || from.unit !== "byte")
362
+ return { ...await deps.plain(entry.event_cursor, timeoutSeconds), steer: fate("unknown") };
363
+ const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
364
+ const record = new LineFollower(transcript, from.boundary);
365
+ let events = new LineFollower(meta.event_file, entry.event_cursor);
366
+ const sentAt = Date.parse(entry.at);
367
+ const deadline = performance.now() + timeoutSeconds * 1000;
368
+ let malformedEvents = 0;
369
+ // 待ち行列に入った、こちらの文(removeのcontentと比べる)。
370
+ let queued = null;
371
+ // こちらの文でない入力(待ち行列の行か、番を始めた文)が在ったか。在る時は、見分けを誤ったかも知れないので「入っていない」と言わない。
372
+ let otherInput = false;
373
+ let taken = null;
374
+ // 文が取られた後に書かれた、sidechainでないassistantの文の指紋と、その行の時刻。
375
+ const answers = new Map();
376
+ const stops = [];
377
+ let rateLimitAt = 0;
378
+ const observed = (outcome, done, steer) => ({
379
+ ...baseObservation(meta), outcome, operation_id: done?.operation_id ?? null,
380
+ vendor_session_id: done?.vendor_session_id ?? meta.vendor_session_id ?? null, malformed_events: malformedEvents, at: done?.at ?? null, steer,
381
+ });
382
+ for (;;) {
383
+ deps.signal?.throwIfAborted();
384
+ if (!fs.existsSync(metadataFile))
385
+ return observed("closed", null, taken ?? fate("unknown"));
386
+ const lines = record.read();
387
+ if (lines === null) {
388
+ // 会話の記録が短くなった(書き直された)。控えた位置が使えないので、今までの待ち(打った後の最初の完了)へ渡す。
389
+ const remaining = Math.max(0, Math.ceil((deadline - performance.now()) / 1000));
390
+ return { ...await deps.plain(entry.event_cursor, remaining), steer: taken ?? fate("unknown") };
391
+ }
392
+ for (const line of lines) {
393
+ if (!line.trim())
394
+ continue;
395
+ let parsed;
396
+ try {
397
+ parsed = JSON.parse(line);
398
+ }
399
+ catch {
400
+ continue;
401
+ }
402
+ const stamp = typeof parsed?.timestamp === "string" ? parsed.timestamp : null;
403
+ if (parsed?.type === "queue-operation") {
404
+ const content = typeof parsed.content === "string" ? parsed.content : "";
405
+ if (parsed.operation === "enqueue") {
406
+ if (!taken && queued === null && entry.needle && squeeze(content).includes(entry.needle))
407
+ queued = content;
408
+ else if (!content.startsWith("<task-notification>"))
409
+ otherInput = true;
410
+ }
411
+ else if (parsed.operation === "remove" && !taken && queued !== null && content === queued) {
412
+ taken = fate("same_turn", stamp);
413
+ }
414
+ continue;
415
+ }
416
+ const prompt = claudePromptText(parsed);
417
+ if (prompt !== null) {
418
+ if (!taken && entry.needle && squeeze(prompt).includes(entry.needle))
419
+ taken = fate("next_turn", stamp);
420
+ else if (!prompt.startsWith("<task-notification>"))
421
+ otherInput = true;
422
+ continue;
423
+ }
424
+ if (!taken || parsed?.type !== "assistant" || parsed.isSidechain === true)
425
+ continue;
426
+ // Stopが来ない終わり方(APIエラー)は、今までの待ちと同じく、誤りとして返す。
427
+ const failed = claudeApiErrorFromLine(line);
428
+ if (failed)
429
+ return { ...observed("error", null, taken), at: failed.at, error: failed.text };
430
+ const text = claudeTextBlocks(parsed.message?.content);
431
+ const at = stamp ? Date.parse(stamp) : NaN;
432
+ answers.set(createHash("sha256").update(text, "utf8").digest("hex"), Number.isFinite(at) ? at : -Infinity);
433
+ }
434
+ const added = events.read();
435
+ if (added === null) {
436
+ events = new LineFollower(meta.event_file, 0);
437
+ stops.length = 0;
438
+ continue;
439
+ }
440
+ for (const line of added) {
441
+ if (!line.trim())
442
+ continue;
443
+ const done = deps.parseDone(line);
444
+ if (done)
445
+ stops.push(done);
446
+ }
447
+ if (taken) {
448
+ for (const done of stops) {
449
+ const written = done.result_digest ? answers.get(done.result_digest) : undefined;
450
+ if (written === undefined)
451
+ continue;
452
+ const at = Date.parse(done.at);
453
+ if (Number.isFinite(at) && at + CLAUDE_STOP_AFTER_ANSWER_SLACK_MS >= written)
454
+ return observed("done", done, taken);
455
+ }
456
+ }
457
+ else if (!record.behind && !events.behind) {
458
+ if (stops.length) {
459
+ // 番は終わったのに、文が取り込まれも次の番にもなっていない。
460
+ const ended = Date.parse(stops[0].at);
461
+ if (now() - (Number.isFinite(ended) ? ended : now()) >= STEER_AFTER_TURN_GRACE_MS) {
462
+ return queued !== null || otherInput ? observed("done", stops[0], fate("unknown")) : observed("not_taken", null, fate("unknown"));
463
+ }
464
+ }
465
+ else if (queued === null && !otherInput && Number.isFinite(sentAt) && now() - sentAt >= STEER_NOT_TAKEN_GRACE_MS) {
466
+ // 打った後、待ち行列の行も番を始めた文の行も無い。文は会話へ入っていない(選ぶ画面に消えた、など)。
467
+ return observed("not_taken", null, fate("unknown"));
468
+ }
469
+ }
470
+ if (performance.now() >= rateLimitAt) {
471
+ rateLimitAt = performance.now() + AGENT_RATE_LIMIT_POLL_MS;
472
+ const limited = deps.rateLimit();
473
+ if (limited)
474
+ return { ...observed("rate_limited", null, taken ?? fate("unknown")), rate_limit: limited };
475
+ }
476
+ if (performance.now() >= deadline)
477
+ return observed(timeoutSeconds === 0 ? "running" : "timeout", null, taken ?? fate("unknown"));
478
+ await sleep(AGENT_DONE_POLL_MS);
479
+ }
480
+ }
481
+ // 席ごとに、記録をどこまで読んだかと、最後の番の境界を覚える。次の回は増えた分だけを読む(全席を数秒おきに聞かれても重くならないように)。
482
+ const codexSeatTracks = new Map();
483
+ const CODEX_SEAT_TRACKS_MAX = 512;
484
+ // 記録が増えていない席で、今の会話の記録を探し直す間隔。探し直しは会話の置き場を一覧するので、聞かれるたびにはやらない。
485
+ // 席の中で会話を移した後(/new など。ADR 0105)、次の送信が席を結び付け直すまでの間も、新しい会話の番を読むためにある。
486
+ const CODEX_SEAT_RESOLVE_MS = 60_000;
487
+ // 会話がまだ結び付いていない席(起こしてから、2つめの送信までの間)で、記録が見つからなかった時に、次に探すまでの間。
488
+ // 結び付く前の席は、会話の置き場の一覧と、起動より後に書かれた記録の頭から、この起動の印を探す。聞かれるたびにはやらない。
489
+ const CODEX_SEAT_UNBOUND_RETRY_MS = 5_000;
490
+ const codexSeatUnresolved = new Map();
491
+ function codexTurnMark(line, offset) {
492
+ if (!line.includes("task_started") && !line.includes("task_complete") && !line.includes("turn_aborted"))
493
+ return undefined;
494
+ let record;
495
+ try {
496
+ record = JSON.parse(line);
497
+ }
498
+ catch {
499
+ return undefined;
500
+ }
501
+ const type = record?.type === "event_msg" ? record?.payload?.type : null;
502
+ if (type !== "task_started" && type !== "task_complete" && type !== "turn_aborted")
503
+ return undefined;
504
+ return { type, turn_id: typeof record.payload.turn_id === "string" && record.payload.turn_id ? record.payload.turn_id : null,
505
+ at: typeof record.timestamp === "string" ? record.timestamp : null, offset };
506
+ }
507
+ function codexSeatTranscript(meta) {
508
+ try {
509
+ return codexRootTranscript(meta);
510
+ }
511
+ catch {
512
+ return null;
513
+ }
514
+ }
515
+ /**
516
+ * Codexの席の、記録の上の今の番。番の開始の印(task_started)が最後の境界なら動いている。
517
+ * 動いている番のevent_cursorとinterim_fromは、開始の印の行の頭(そこから待てば、その番の終わりで返り、番の頭からの合間の言葉を読める)。
518
+ * 記録がまだ無い・末尾を書いている途中の席はnull(分からない)。
519
+ * 会話を結び付ける前の席(vendor_session_idがまだ無い)も、この起動の印を持つ記録から読む。席の登録は書き換えない。
520
+ * 起こしたばかりの席は、最初の送信の時に記録がまだ無く、2つめの送信まで結び付かない。子の結果が2つめの番を始める席は、この形になる
521
+ * (実物 2026-10-13: 最初の番の間も、終わった後も、会話のIDは席の登録に入っていなかった)。
522
+ * harnessが落ちた席は、終わりの印が書かれないので、動いていると読んだままになる(呼び手は、席が閉じた事を待ちのclosedで知る)。
523
+ */
524
+ export function codexSeatTurn(meta, now = performance.now()) {
525
+ if (meta.kind !== "codex")
526
+ return null;
527
+ const key = `${meta.launch_id}:${meta.vendor_session_id ?? ""}`;
528
+ const id = meta.aiterm_session;
529
+ let track = codexSeatTracks.get(id);
530
+ if (track && track.key === key && safeStatSize(track.file) === track.offset && now - track.resolvedAt >= CODEX_SEAT_RESOLVE_MS) {
531
+ track.resolvedAt = now;
532
+ const current = codexSeatTranscript(meta);
533
+ if (current && current !== track.file)
534
+ track = undefined;
535
+ }
536
+ if (!track || track.key !== key || !fs.existsSync(track.file)) {
537
+ const unresolved = codexSeatUnresolved.get(id);
538
+ if (unresolved && unresolved.key === key && now < unresolved.retryAt)
539
+ return null;
540
+ const file = codexSeatTranscript(meta);
541
+ if (!file) {
542
+ codexSeatTracks.delete(id);
543
+ if (meta.vendor_session_id)
544
+ codexSeatUnresolved.delete(id);
545
+ else {
546
+ codexSeatUnresolved.delete(id);
547
+ if (codexSeatUnresolved.size >= CODEX_SEAT_TRACKS_MAX)
548
+ codexSeatUnresolved.delete(codexSeatUnresolved.keys().next().value);
549
+ codexSeatUnresolved.set(id, { key, retryAt: now + CODEX_SEAT_UNBOUND_RETRY_MS });
550
+ }
551
+ return null;
552
+ }
553
+ codexSeatUnresolved.delete(id);
554
+ track = { key, file, offset: -1, last: null, resolvedAt: now };
555
+ codexSeatTracks.delete(id);
556
+ if (codexSeatTracks.size >= CODEX_SEAT_TRACKS_MAX)
557
+ codexSeatTracks.delete(codexSeatTracks.keys().next().value);
558
+ codexSeatTracks.set(id, track);
559
+ }
560
+ const size = safeStatSize(track.file);
561
+ if (track.offset < 0 || size < track.offset) {
562
+ // 初めて見る席(と、記録が短くなった席)は、末尾からさかのぼって最後の境界を探す。長い番の開始の印は、末尾から遠い。
563
+ // 読んだ事にする位置は、さかのぼる前に取った大きさ。その後に足された行は、次の回に増えた分として読む(同じ行を2回読んでも結果は同じ)。
564
+ track.offset = -1;
565
+ if (size > 0 && readFileRange(track.file, size - 1, size)[0] !== 0x0a)
566
+ return null;
567
+ const mark = scanLinesNewestFirst(track.file, codexTurnMark, AGENT_TURN_BOUNDARY_TAIL_BYTES);
568
+ if (mark === null)
569
+ return null;
570
+ track.last = mark ?? null;
571
+ track.offset = size;
572
+ }
573
+ else if (size > track.offset) {
574
+ let offset = track.offset;
575
+ while (offset < size) {
576
+ const chunk = readFileRange(track.file, offset, Math.min(size, offset + STEER_READ_CHUNK_BYTES));
577
+ if (chunk.length === 0)
578
+ break;
579
+ const last = chunk.lastIndexOf(0x0a);
580
+ if (last < 0) {
581
+ // 行が読む範囲に収まらない。末尾を書いている途中なら、そろうのを次の回まで待つ。
582
+ // 範囲を使い切った長い行(道具の出力など)は境界の行ではないので、その行の終わりまで読み飛ばす。
583
+ if (chunk.length < STEER_READ_CHUNK_BYTES)
584
+ break;
585
+ offset += chunk.length;
586
+ continue;
587
+ }
588
+ for (let from = 0; from <= last;) {
589
+ const index = chunk.indexOf(0x0a, from);
590
+ const mark = codexTurnMark(chunk.subarray(from, index).toString("utf8"), offset + from);
591
+ if (mark)
592
+ track.last = mark;
593
+ from = index + 1;
594
+ }
595
+ offset += last + 1;
596
+ }
597
+ track.offset = offset;
598
+ }
599
+ const last = track.last;
600
+ if (last?.type === "task_started" && last.turn_id) {
601
+ return { state: "running", turn_id: last.turn_id, started_at: last.at, event_cursor: last.offset,
602
+ interim_from: interimFromToken({ event_cursor: last.offset, unit: "byte", boundary: last.offset }) };
603
+ }
604
+ return { state: "idle" };
605
+ }
606
+ /** 試験用: 席ごとの読み位置の覚えを捨てる。 */
607
+ export function __testResetSeatTurns() {
608
+ codexSeatTracks.clear();
609
+ codexSeatUnresolved.clear();
610
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "mcpName": "io.github.kitepon/aiterm-mcp",
5
5
  "description": "Persistent terminal MCP with one harness-based launcher for Claude Code, Codex CLI, Grok CLI, and Cursor Agent CLI, plus durable PTYs for SSH, containers, and REPLs.",
6
6
  "keywords": [
@@ -68,11 +68,11 @@
68
68
  "test:docs": "node --import ./test/seat-env.mjs --test test/repository-contract.test.mjs",
69
69
  "prepublishOnly": "npm run verify:release-commit && npm run build",
70
70
  "start": "node dist/index.js",
71
- "test": "npm run build && node --import ./test/seat-env.mjs --test scripts/verify-release-commit.test.mjs test/*.test.mjs"
71
+ "test": "npm run build && node --import ./test/seat-env.mjs --test test/seat-env.test.mjs && node --import ./test/seat-env.mjs --test scripts/verify-release-commit.test.mjs test/*.test.mjs"
72
72
  },
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "^1.29.0",
75
- "aiterm-steer-delivery": "^0.4.6",
75
+ "aiterm-steer-delivery": "^0.4.7",
76
76
  "ws": "8.21.3",
77
77
  "zod": "^4.4.3"
78
78
  },