tailii-host 0.12.0 → 0.14.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.
Files changed (70) hide show
  1. package/dist/backend/tmux.d.ts +40 -0
  2. package/dist/backend/tmux.js +106 -1
  3. package/dist/backend/tmux.js.map +1 -1
  4. package/dist/chat/subagentTranscript.js +29 -0
  5. package/dist/chat/subagentTranscript.js.map +1 -1
  6. package/dist/chat/transcriptTailer.d.ts +2 -0
  7. package/dist/chat/transcriptTailer.js +50 -4
  8. package/dist/chat/transcriptTailer.js.map +1 -1
  9. package/dist/codex/codexAppServer.d.ts +2 -0
  10. package/dist/codex/codexAppServer.js +21 -2
  11. package/dist/codex/codexAppServer.js.map +1 -1
  12. package/dist/codex/codexAsyncQuestion.d.ts +53 -0
  13. package/dist/codex/codexAsyncQuestion.js +186 -0
  14. package/dist/codex/codexAsyncQuestion.js.map +1 -0
  15. package/dist/codex/codexNativeTurnController.d.ts +62 -0
  16. package/dist/codex/codexNativeTurnController.js +381 -7
  17. package/dist/codex/codexNativeTurnController.js.map +1 -1
  18. package/dist/codex/codexRolloutTailer.d.ts +5 -0
  19. package/dist/codex/codexRolloutTailer.js +50 -7
  20. package/dist/codex/codexRolloutTailer.js.map +1 -1
  21. package/dist/codex/codexSessionStore.d.ts +16 -1
  22. package/dist/codex/codexSessionStore.js +87 -18
  23. package/dist/codex/codexSessionStore.js.map +1 -1
  24. package/dist/codex/codexSystemNotice.d.ts +7 -0
  25. package/dist/codex/codexSystemNotice.js +13 -0
  26. package/dist/codex/codexSystemNotice.js.map +1 -1
  27. package/dist/engine/engine.js +22 -4
  28. package/dist/engine/engine.js.map +1 -1
  29. package/dist/engine/handlers/conversation.js +83 -15
  30. package/dist/engine/handlers/conversation.js.map +1 -1
  31. package/dist/engine/handlers/mode.js +93 -3
  32. package/dist/engine/handlers/mode.js.map +1 -1
  33. package/dist/engine/handlers/session.js +9 -4
  34. package/dist/engine/handlers/session.js.map +1 -1
  35. package/dist/hub/historyPage.d.ts +22 -0
  36. package/dist/hub/historyPage.js +125 -0
  37. package/dist/hub/historyPage.js.map +1 -0
  38. package/dist/hub/hubDaemon.js +6 -2
  39. package/dist/hub/hubDaemon.js.map +1 -1
  40. package/dist/hub/hubProtocol.d.ts +39 -2
  41. package/dist/hub/hubProtocol.js +47 -2
  42. package/dist/hub/hubProtocol.js.map +1 -1
  43. package/dist/hub/panePreviewPump.d.ts +23 -0
  44. package/dist/hub/panePreviewPump.js +55 -1
  45. package/dist/hub/panePreviewPump.js.map +1 -1
  46. package/dist/hub/questionInjection.d.ts +7 -0
  47. package/dist/hub/questionInjection.js +18 -0
  48. package/dist/hub/questionInjection.js.map +1 -1
  49. package/dist/hub/sessionHub.d.ts +42 -1
  50. package/dist/hub/sessionHub.js +336 -5
  51. package/dist/hub/sessionHub.js.map +1 -1
  52. package/dist/protocol/decode.d.ts +2 -0
  53. package/dist/protocol/decode.js +65 -1
  54. package/dist/protocol/decode.js.map +1 -1
  55. package/dist/protocol/messages.d.ts +82 -1
  56. package/dist/sessions/claudeSessionStore.d.ts +13 -1
  57. package/dist/sessions/claudeSessionStore.js +67 -7
  58. package/dist/sessions/claudeSessionStore.js.map +1 -1
  59. package/dist/sessions/listMemoFile.d.ts +24 -0
  60. package/dist/sessions/listMemoFile.js +133 -0
  61. package/dist/sessions/listMemoFile.js.map +1 -0
  62. package/dist/shared/clientBuild.d.ts +1 -1
  63. package/dist/shared/clientBuild.js +1 -1
  64. package/dist/shared/readFailures.d.ts +4 -0
  65. package/dist/shared/readFailures.js +20 -0
  66. package/dist/shared/readFailures.js.map +1 -0
  67. package/dist/shared/usageLimitWait.d.ts +28 -1
  68. package/dist/shared/usageLimitWait.js +197 -16
  69. package/dist/shared/usageLimitWait.js.map +1 -1
  70. package/package.json +3 -3
@@ -5,8 +5,10 @@ import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { bumpHeartbeat, listHeartbeatSessions, readHeartbeat, writeHeartbeat } from "../sessions/heartbeat.js";
7
7
  import { ensureDirectory0700 } from "../shared/paths.js";
8
+ import { encodeControlMessage } from "../protocol.js";
8
9
  import { decodeHubClientLine, encodeHubMessage, } from "./hubProtocol.js";
9
10
  import { HISTORY_DONE_STREAM_ID, findTrailingTurnEndMarkerMs, findTrailingUserPromptText, } from "../chat/transcriptTailer.js";
11
+ import { sliceHistoryPage } from "./historyPage.js";
10
12
  import { sameUsageLimitWait } from "../shared/usageLimitWait.js";
11
13
  import { PROTOCOL_V1, PROTOCOL_V2 } from "../protocol.js";
12
14
  import { ChatInjectionRejectedError, LoginCodeError, SEND_NOW_BLOCKED_BY_DIALOG, SEND_NOW_EXPIRED_IN_QUEUE, } from "../backend/tmux.js";
@@ -14,12 +16,19 @@ import { CodexAppServerManager } from "../codex/codexAppServer.js";
14
16
  import { CodexNativeTurnController, chatContentKey, } from "../codex/codexNativeTurnController.js";
15
17
  import { COLLABORATION_MODE_STREAM_ID as CODEX_COLLABORATION_MODE_STREAM_ID, CONTEXT_STREAM_ID as CODEX_CONTEXT_STREAM_ID, CONTEXT_WINDOW_STREAM_ID as CODEX_CONTEXT_WINDOW_STREAM_ID, MODEL_STREAM_ID as CODEX_MODEL_STREAM_ID, } from "../codex/codexRolloutTailer.js";
16
18
  import { claudeTranscriptPathFor } from "../sessions/transcriptPath.js";
19
+ import { isNonBlockingQuestionId } from "../codex/codexAsyncQuestion.js";
17
20
  /**
18
21
  * backfilling 中の購読者は publishConversationEvent が丸ごと飛ばす。旗が立ちっぱなしになると
19
22
  * 「接続は健全なのに会話だけ永久に更新されない」実障害になるため、履歴が一定時間 1 行も進まな
20
23
  * ければ強制的に live へ切り替える。進捗があるうちは延長するので、巨大な transcript の再生を
21
24
  * 途中で打ち切ることはない。
22
25
  */
26
+ /** 履歴ページ用に transcript を読み切るまでの上限(画像の多い巨大な会話でも返事を返す)。 */
27
+ const HISTORY_PAGE_READ_TIMEOUT_MS = 20_000;
28
+ /** 履歴ページ用に全履歴を覚えておく会話の数。 */
29
+ const HISTORY_PAGE_CACHE_LIMIT = 3;
30
+ /** 履歴ページ用に覚えた全履歴を使い回す期間(遡り中の連続要求だけを速くし、メモリに長く残さない)。 */
31
+ const HISTORY_PAGE_CACHE_TTL_MS = 10 * 60_000;
23
32
  const BACKFILL_STALL_TIMEOUT_MS = 15_000;
24
33
  const DELIVERED_RECEIPT_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
25
34
  const MAX_DELIVERED_RECEIPTS_PER_SESSION = 10_000;
@@ -46,11 +55,20 @@ function isContinuationHookEvent(event) {
46
55
  function defaultTranscriptPathFor(meta) {
47
56
  return claudeTranscriptPathFor(meta);
48
57
  }
58
+ /** 設問の回答時に TUI へ設問ダイアログが出ていなかった(question-stale)。キーは 1 つも打っていない。 */
59
+ class StaleQuestionError extends Error {
60
+ constructor() {
61
+ super("AskUserQuestion dialog is not on screen");
62
+ this.name = "StaleQuestionError";
63
+ }
64
+ }
49
65
  export class SessionHub {
50
66
  options;
51
67
  clients = new Map();
52
68
  /** 一覧 Mission Control の watcher(処理中会話全体の pane_preview 配信先)。 */
53
69
  previewWatchers = new Set();
70
+ /** `stale` で捨てた設問の再確認タイマー(会話ごとに 1 本, question-stale)。 */
71
+ staleQuestionRechecks = new Map();
54
72
  actors = new Map();
55
73
  now;
56
74
  nowMs;
@@ -59,6 +77,13 @@ export class SessionHub {
59
77
  codexStartsInFlight = 0;
60
78
  modePushSeq = 0;
61
79
  chatReceiptsDirty = false;
80
+ /**
81
+ * 片付け(retire)・世代不一致で actor ごと消えた会話の Claude 配送済み receipt(clientMessageId →
82
+ * 配送時刻)。clientMessageId は UUID で会話をまたいで一意なので、actor が消えても「配送済み」の
83
+ * 事実は変わらない。`chat_receipt_query` がこれを見て、ACK を取りこぼした iOS の Outbox を
84
+ * 安全に解決できるようにする(restored-receipt-probe)。保持は通常の receipt と同じ 30 日。
85
+ */
86
+ retiredChatDeliveries = new Map();
62
87
  activeCodexTurns = new Set();
63
88
  codexTurnController = null;
64
89
  constructor(options) {
@@ -176,12 +201,19 @@ export class SessionHub {
176
201
  /** daemon 再起動前に durable enqueue 済みだった chat_send と receipt を復元する。 */
177
202
  restoreChatReceipts() {
178
203
  const persisted = this.readChatReceipts();
204
+ const restoredLedgerAt = Date.now();
205
+ for (const [clientMessageId, deliveredAt] of Object.entries(this.readRetiredChatDeliveries())) {
206
+ this.retiredChatDeliveries.set(clientMessageId, deliveredAt);
207
+ }
208
+ compactRetiredChatDeliveries(this.retiredChatDeliveries, restoredLedgerAt);
179
209
  let discardedStaleSession = false;
180
210
  for (const [session, state] of Object.entries(persisted)) {
181
211
  const currentSessionIdentity = receiptSessionIdentity(this.options.metadataStore.get(session));
182
212
  if (state.sessionIdentity !== undefined && state.sessionIdentity !== currentSessionIdentity) {
183
213
  // session 名は再利用される。旧世代の durable queue を同名の新 pane へ注入しない。
184
214
  // metadata が消失・破損した場合も identity を証明できないため安全側で破棄する。
215
+ // 配送済みの事実(UUID の clientMessageId)だけは照会用の台帳へ残す。
216
+ adoptRetiredChatDeliveries(this.retiredChatDeliveries, new Map(state.delivered.map((id) => [id, state.deliveredAtMs?.[id] ?? restoredLedgerAt])), restoredLedgerAt);
185
217
  discardedStaleSession = true;
186
218
  this.options.log?.(`stale chat receipt 破棄 session=${session}`);
187
219
  continue;
@@ -288,6 +320,10 @@ export class SessionHub {
288
320
  }
289
321
  handleRelayMessage(message) {
290
322
  if (message.type === "question_event") {
323
+ // 新しい設問が来た / CLI 側でその設問が片付いた(PostToolUse)なら、捨てた設問の再確認は要らない。
324
+ if (message.event === "prompt" || this.staleQuestionRechecks.get(message.session)?.id === message.id) {
325
+ this.cancelStaleQuestionRecheck(message.session);
326
+ }
291
327
  const actor = this.actor(message.session);
292
328
  this.setPendingQuestion(message.session, actor, message.event === "prompt"
293
329
  ? { id: message.id, questions: message.questions ?? [], answerRoute: "tui" } : null);
@@ -335,6 +371,10 @@ export class SessionHub {
335
371
  }
336
372
  return;
337
373
  }
374
+ if (message.type === "conversation_history_page_request") {
375
+ void this.answerHistoryPage(client, message);
376
+ return;
377
+ }
338
378
  if (message.type === "conversation_subagent_transcript_request") {
339
379
  const actor = this.actors.get(message.session);
340
380
  const transcript = actor?.tail?.subagentTranscript?.(message.nodeId) ?? {
@@ -397,8 +437,8 @@ export class SessionHub {
397
437
  const restoreSnapshot = actor.pendingQuestion;
398
438
  this.setPendingQuestion(message.session, actor, null);
399
439
  this.broadcast({ type: "question_event", session: message.session, event: "dismiss", id: message.questionId });
400
- this.sendTo(client, { type: "question_answer_result", id: message.id, status: "accepted" });
401
440
  if (answerRoute === "codex_native") {
441
+ this.sendTo(client, { type: "question_answer_result", id: message.id, status: "accepted" });
402
442
  try {
403
443
  if (this.codexTurnController?.answerQuestion?.(message.questionId, message.answers) !== true) {
404
444
  this.options.log?.(`Codex native 設問回答失敗: pending 不在 (${message.questionId})`);
@@ -415,15 +455,39 @@ export class SessionHub {
415
455
  actor.questionAnswerInjections += 1;
416
456
  // 同期 throw でも `.finally` を必ず通す(通らないと回答注入中の件数が戻らず、chat が
417
457
  // 永久に保留される)。
458
+ // 回答キーは TUI に設問ダイアログが出ているときだけ打つ(question-stale)。出ていなければ
459
+ // 数字キーは入力欄へ落ち、Enter の無い単一選択では「1」が入ったまま会話が止まる
460
+ // (実機 2026-10-05: 設問カードに答えたら CLI の入力欄に 1 が残り、アプリは処理中のまま)。
461
+ // 判定できない(pane を読めない)ときは従来どおり打つ。確認しない構成では従来どおり即受理。
462
+ const dialogVisible = this.options.questionDialogVisible;
463
+ const accept = () => {
464
+ this.sendTo(client, { type: "question_answer_result", id: message.id, status: "accepted" });
465
+ };
466
+ if (dialogVisible === undefined)
467
+ accept();
418
468
  let injection;
419
469
  try {
420
- injection = this.options.questionInjector?.(message.answers, message.session) ?? Promise.resolve();
470
+ injection = (async () => {
471
+ if (dialogVisible !== undefined) {
472
+ if (await dialogVisible(message.session) === false)
473
+ throw new StaleQuestionError();
474
+ accept();
475
+ }
476
+ await this.options.questionInjector?.(message.answers, message.session);
477
+ })();
421
478
  }
422
479
  catch (error) {
423
480
  injection = Promise.reject(error);
424
481
  }
425
482
  void injection
426
483
  .catch((error) => {
484
+ if (error instanceof StaleQuestionError) {
485
+ // 設問はもう TUI に無い(回答済み・取り消し済み・表示されなかった)。復元も再提示もしない。
486
+ this.sendTo(client, { type: "question_answer_result", id: message.id, status: "stale" });
487
+ this.options.log?.(`audit question-stale session=${auditValue(message.session)} question=${auditValue(message.questionId)}`);
488
+ this.recheckStaleQuestion(message.session, actor, restoreSnapshot);
489
+ return;
490
+ }
427
491
  this.options.log?.(`設問回答注入失敗: ${String(error)}`);
428
492
  // 自己修復(question-answer-retry): pendingQuestion を失ったまま TUI ダイアログが
429
493
  // 残ると、アプリから再回答する手段が無く会話が詰む。失敗の度に必ず復元し、
@@ -678,6 +742,13 @@ export class SessionHub {
678
742
  void this.runSendNow(message.session, actor);
679
743
  return;
680
744
  }
745
+ if (message.type === "chat_receipt_query") {
746
+ this.sendTo(client, {
747
+ type: "chat_receipt_query_result", id: message.id,
748
+ status: this.chatReceiptStatus(message.session, message.clientMessageId),
749
+ });
750
+ return;
751
+ }
681
752
  if (message.type === "pending_message_delete") {
682
753
  // 対象不在も成功だが、client 保存前 crash で同じ Outbox が復元されても実行しないよう
683
754
  // 削除 tombstone は durable に残す。actor 不在でもここだけは軽量 actor を作る。
@@ -761,6 +832,9 @@ export class SessionHub {
761
832
  return result;
762
833
  }
763
834
  close() {
835
+ for (const recheck of this.staleQuestionRechecks.values())
836
+ clearTimeout(recheck.timer);
837
+ this.staleQuestionRechecks.clear();
764
838
  for (const actor of this.actors.values()) {
765
839
  if (actor.tailRetryTimer !== null)
766
840
  clearTimeout(actor.tailRetryTimer);
@@ -827,6 +901,7 @@ export class SessionHub {
827
901
  }
828
902
  // drain が injector / App Server の await 中でも、復帰後に同じ actor 参照から次の旧入力へ
829
903
  // 進めないよう durable queue の実体も空にする。現在実行中の1件は復帰時のidentity検査で捨てる。
904
+ adoptRetiredChatDeliveries(this.retiredChatDeliveries, actor.deliveredChatMessageIds);
830
905
  actor.chatQueue.length = 0;
831
906
  actor.chatOrder.length = 0;
832
907
  actor.pendingChatMessages.clear();
@@ -890,6 +965,22 @@ export class SessionHub {
890
965
  actor.previewPump?.resendLastIfInteractive?.();
891
966
  actor.previewPump?.armJumpToLatest?.();
892
967
  }
968
+ // 開き直し(reattach-since)は iOS のキャッシュが追いついていた時刻以降を transcript から
969
+ // 読み直す。背景購読の replay buffer だけでは履歴完了マーカーが届かず、iOS の
970
+ // 「最新分を取り込み中」が解けない。
971
+ if (!existing.backfilling && newerThanMs !== undefined) {
972
+ if (this.options.tailFactory === undefined || this.options.metadataStore.get(session) === null) {
973
+ // 読み直せない会話でも完了を知らせる(iOS の「最新分を取り込み中」と遡りの待ちを解く)。
974
+ this.sendTo(client, {
975
+ type: "conversation_event", session, serverSeq: 0,
976
+ payload: { type: "chat_output", v: PROTOCOL_V1, streamId: "pc:history-cancelled",
977
+ role: "system", text: "", eof: true },
978
+ });
979
+ return;
980
+ }
981
+ this.startBackfill(client, session, actor, newerThanMs);
982
+ return;
983
+ }
893
984
  // preview=false 中に engine が route できなかった image/subagent event を、同じ
894
985
  // subscriber の前面昇格時にも afterSeq から回収する。既存購読だからと no-op にしない。
895
986
  if (!existing.backfilling && afterSeq !== undefined) {
@@ -1463,6 +1554,114 @@ export class SessionHub {
1463
1554
  if (!finished)
1464
1555
  armStallTimer(BACKFILL_STALL_TIMEOUT_MS);
1465
1556
  }
1557
+ // ---- 履歴ページ(history-page) ----
1558
+ /** 直近に読んだ会話の全履歴(ページを遡るたびに transcript を頭から読み直さない)。 */
1559
+ historyPageCache = new Map();
1560
+ /** 会話ごとの読み取り中の Promise(同じ会話の全履歴を並行して二重に読まない)。 */
1561
+ historyPageReads = new Map();
1562
+ async answerHistoryPage(client, message) {
1563
+ const anchor = message.beforeStreamId !== undefined || message.beforeToolId !== undefined
1564
+ ? { ...(message.beforeStreamId !== undefined ? { streamId: message.beforeStreamId } : {}),
1565
+ ...(message.beforeToolId !== undefined ? { toolId: message.beforeToolId } : {}) }
1566
+ : null;
1567
+ let response;
1568
+ try {
1569
+ // 最新のページは常に読み直す(新しい行が増えている)。遡るページは直近の読み取りを使い、
1570
+ // 終端の行がそこに無いときだけ読み直す。
1571
+ // 期限切れの全履歴は要求のたびに捨てる(常駐 daemon に base64 サムネごと残し続けない)。
1572
+ for (const [session, cached] of this.historyPageCache) {
1573
+ if (Date.now() - cached.hostNowMs > HISTORY_PAGE_CACHE_TTL_MS)
1574
+ this.historyPageCache.delete(session);
1575
+ }
1576
+ let history = anchor === null ? undefined : this.historyPageCache.get(message.session);
1577
+ let slice = history === undefined ? null : sliceHistoryPage(history.events, anchor, message.limit);
1578
+ if (slice === null || !slice.anchorFound) {
1579
+ history = await this.readFullHistory(message.session);
1580
+ slice = sliceHistoryPage(history.events, anchor, message.limit);
1581
+ }
1582
+ response = {
1583
+ type: "conversation_history_page_response", id: message.id, session: message.session,
1584
+ lines: slice.events.map((event) => encodeControlMessage(event)),
1585
+ hasMore: slice.hasMore, hostNowMs: history.hostNowMs,
1586
+ ...(slice.anchorFound ? {} : { anchorMissing: true }),
1587
+ };
1588
+ }
1589
+ catch (error) {
1590
+ this.options.log?.(`audit history-page-failed session=${message.session} error=${String(error)}`);
1591
+ response = {
1592
+ type: "conversation_history_page_response", id: message.id, session: message.session,
1593
+ lines: [], hasMore: false, hostNowMs: Date.now(), failed: true,
1594
+ };
1595
+ }
1596
+ this.sendTo(client, response);
1597
+ }
1598
+ /** 全履歴の再送と同じ tail で transcript を最後まで読み、出力を集める(購読者へは流さない)。 */
1599
+ readFullHistory(session) {
1600
+ const inFlight = this.historyPageReads.get(session);
1601
+ if (inFlight !== undefined)
1602
+ return inFlight;
1603
+ const read = new Promise((resolve, reject) => {
1604
+ const factory = this.options.tailFactory;
1605
+ const meta = this.options.metadataStore.get(session);
1606
+ if (factory === undefined || meta === null) {
1607
+ reject(new Error(factory === undefined ? "tail unavailable" : "session metadata not found"));
1608
+ return;
1609
+ }
1610
+ const hostNowMs = Date.now();
1611
+ const events = [];
1612
+ let settled = false;
1613
+ let tail = null;
1614
+ let timer = null;
1615
+ const settle = (error) => {
1616
+ if (settled)
1617
+ return;
1618
+ settled = true;
1619
+ if (timer !== null)
1620
+ clearTimeout(timer);
1621
+ tail?.stop();
1622
+ if (error !== null) {
1623
+ reject(error);
1624
+ return;
1625
+ }
1626
+ const history = { events, hostNowMs };
1627
+ // 直近数件だけ持つ(会話を行き来しても読み直しを減らし、メモリは際限なく持たない)。
1628
+ this.historyPageCache.delete(session);
1629
+ this.historyPageCache.set(session, history);
1630
+ while (this.historyPageCache.size > HISTORY_PAGE_CACHE_LIMIT) {
1631
+ this.historyPageCache.delete(this.historyPageCache.keys().next().value);
1632
+ }
1633
+ resolve(history);
1634
+ };
1635
+ tail = factory((payload) => {
1636
+ if (settled)
1637
+ return;
1638
+ if (payload.type === "chat_output" && payload.streamId === HISTORY_DONE_STREAM_ID) {
1639
+ settle(null);
1640
+ return;
1641
+ }
1642
+ events.push(payload);
1643
+ });
1644
+ if (settled) {
1645
+ tail.stop();
1646
+ return;
1647
+ }
1648
+ timer = setTimeout(() => settle(new Error("history read timed out")), HISTORY_PAGE_READ_TIMEOUT_MS);
1649
+ timer.unref();
1650
+ try {
1651
+ tail.open(meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, null, meta.agent ?? "claude");
1652
+ }
1653
+ catch (error) {
1654
+ settle(error instanceof Error ? error : new Error(String(error)));
1655
+ }
1656
+ });
1657
+ this.historyPageReads.set(session, read);
1658
+ const clear = () => {
1659
+ if (this.historyPageReads.get(session) === read)
1660
+ this.historyPageReads.delete(session);
1661
+ };
1662
+ read.then(clear, clear);
1663
+ return read;
1664
+ }
1466
1665
  canReplay(actor, afterSeq) {
1467
1666
  const first = actor.replayBuffer[0]?.serverSeq ?? actor.nextServerSeq;
1468
1667
  const last = actor.nextServerSeq - 1;
@@ -1541,7 +1740,11 @@ export class SessionHub {
1541
1740
  if (actor.usageLimitWatch !== null)
1542
1741
  return 5000;
1543
1742
  return 1000;
1544
- }, (state) => this.handleUsageLimitWait(session, actor, state));
1743
+ }, (state) => this.handleUsageLimitWait(session, actor, state),
1744
+ // 入力欄に残った文字の通知(input-residue)は処理中でない間だけ。hub 自身が発話・設問の回答・
1745
+ // 今すぐ送信を打っている最中の入力欄も、残り文字とみなさない。
1746
+ () => actor.processingSince === null && !actor.chatDrainRunning && actor.questionAnswerInjections === 0 &&
1747
+ !actor.sendNowRunning);
1545
1748
  actor.previewPump = pump;
1546
1749
  pump.start(session, this.options.metadataStore.get(session)?.agent === "codex" ? "codex_terminal" : "claude_status",
1547
1750
  // 処理中の pump 起動(=一覧 watch 起因が典型)は初回フレームから送る。静止 pane
@@ -2372,7 +2575,9 @@ export class SessionHub {
2372
2575
  catch (error) {
2373
2576
  this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
2374
2577
  }
2375
- if (state === "done" && actor.pendingQuestion !== null) {
2578
+ // turn を止めない設問(Codex の非同期質問)は turn controller が turn の終わりに締め切る。処理中表示の
2579
+ // done(steer の不確定失敗などでも来る)で消すと、controller は提示済みのままで出し直さない。
2580
+ if (state === "done" && actor.pendingQuestion !== null && !isNonBlockingQuestionId(actor.pendingQuestion.id)) {
2376
2581
  const id = actor.pendingQuestion.id;
2377
2582
  this.setPendingQuestion(session, actor, null);
2378
2583
  this.broadcast({ type: "question_event", session, event: "dismiss", id });
@@ -2388,6 +2593,63 @@ export class SessionHub {
2388
2593
  this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
2389
2594
  }
2390
2595
  }
2596
+ /**
2597
+ * `stale` で捨てた設問を、少し後に確かめ直す(question-stale)。設問の通知は PreToolUse hook が allow を
2598
+ * 返す前に届くので、利用者の他の遅い hook があるとダイアログはまだ描かれていないことがある。その窓で
2599
+ * 答えられた設問は、ダイアログが出てきたら戻して再提示する。間隔を広げて数回まで確かめ、別の設問が来た・
2600
+ * CLI 側で片付いた(PostToolUse の dismiss)・会話が入れ替わった・hub を閉じたら止める。
2601
+ */
2602
+ recheckStaleQuestion(session, actor, snapshot) {
2603
+ const dialogVisible = this.options.questionDialogVisible;
2604
+ if (dialogVisible === undefined)
2605
+ return;
2606
+ this.cancelStaleQuestionRecheck(session);
2607
+ const delays = this.options.staleQuestionRecheckMs !== undefined
2608
+ ? [this.options.staleQuestionRecheckMs, this.options.staleQuestionRecheckMs * 2, this.options.staleQuestionRecheckMs * 4]
2609
+ : [1_500, 3_000, 6_000];
2610
+ const schedule = (attempt) => {
2611
+ const delay = delays[attempt];
2612
+ if (delay === undefined) {
2613
+ this.cancelStaleQuestionRecheck(session);
2614
+ return;
2615
+ }
2616
+ const timer = setTimeout(() => {
2617
+ void (async () => {
2618
+ const mine = () => this.staleQuestionRechecks.get(session)?.timer === timer;
2619
+ const alive = () => mine() && this.actors.get(session) === actor && actor.pendingQuestion === null;
2620
+ const settle = () => { if (mine())
2621
+ this.staleQuestionRechecks.delete(session); };
2622
+ if (!alive()) {
2623
+ settle();
2624
+ return;
2625
+ }
2626
+ const visible = await dialogVisible(session);
2627
+ if (!alive()) {
2628
+ settle();
2629
+ return;
2630
+ }
2631
+ if (visible !== true) {
2632
+ schedule(attempt + 1);
2633
+ return;
2634
+ }
2635
+ this.cancelStaleQuestionRecheck(session);
2636
+ this.setPendingQuestion(session, actor, snapshot);
2637
+ this.broadcast({ type: "question_event", session, event: "prompt", id: snapshot.id, questions: snapshot.questions });
2638
+ this.options.log?.(`audit question-stale-restored session=${auditValue(session)} question=${auditValue(snapshot.id)}`);
2639
+ })().catch(() => { });
2640
+ }, delay);
2641
+ timer.unref?.();
2642
+ this.staleQuestionRechecks.set(session, { id: snapshot.id, timer });
2643
+ };
2644
+ schedule(0);
2645
+ }
2646
+ cancelStaleQuestionRecheck(session) {
2647
+ const recheck = this.staleQuestionRechecks.get(session);
2648
+ if (recheck === undefined)
2649
+ return;
2650
+ clearTimeout(recheck.timer);
2651
+ this.staleQuestionRechecks.delete(session);
2652
+ }
2391
2653
  setPendingQuestion(session, actor, pending) {
2392
2654
  actor.pendingQuestion = pending;
2393
2655
  this.persistPendingQuestions();
@@ -2491,7 +2753,12 @@ export class SessionHub {
2491
2753
  };
2492
2754
  }
2493
2755
  try {
2494
- const contents = JSON.stringify({ version: 1, sessions });
2756
+ compactRetiredChatDeliveries(this.retiredChatDeliveries);
2757
+ const contents = JSON.stringify({
2758
+ version: 1, sessions,
2759
+ ...(this.retiredChatDeliveries.size > 0
2760
+ ? { retiredChatDeliveredAtMs: Object.fromEntries(this.retiredChatDeliveries) } : {}),
2761
+ });
2495
2762
  if (this.options.chatReceiptsWriter !== undefined) {
2496
2763
  this.options.chatReceiptsWriter(target, contents);
2497
2764
  }
@@ -2510,6 +2777,49 @@ export class SessionHub {
2510
2777
  return false;
2511
2778
  }
2512
2779
  }
2780
+ readRetiredChatDeliveries() {
2781
+ const target = this.options.chatReceiptsPath;
2782
+ if (target === undefined)
2783
+ return {};
2784
+ try {
2785
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
2786
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
2787
+ return {};
2788
+ const ledger = parsed["retiredChatDeliveredAtMs"];
2789
+ if (typeof ledger !== "object" || ledger === null || Array.isArray(ledger))
2790
+ return {};
2791
+ const result = {};
2792
+ for (const [clientMessageId, deliveredAt] of Object.entries(ledger)) {
2793
+ if (typeof deliveredAt === "number" && Number.isFinite(deliveredAt))
2794
+ result[clientMessageId] = deliveredAt;
2795
+ }
2796
+ return result;
2797
+ }
2798
+ catch {
2799
+ return {};
2800
+ }
2801
+ }
2802
+ /**
2803
+ * Claude 発話の配送状態を、何も登録せずに答える(`chat_receipt_query`, restored-receipt-probe)。
2804
+ * `chat_send` と違い、どの記録にも無い ID を新規として受け付けない(unknown を返すだけ)。
2805
+ * 会話が片付いた後も配送済みは台帳から答える。actor が無ければ作らない。
2806
+ */
2807
+ chatReceiptStatus(session, clientMessageId) {
2808
+ const actor = this.actors.get(session);
2809
+ if (actor !== undefined) {
2810
+ if (hasDeliveredReceipt(actor.deletedChatMessageIds, clientMessageId))
2811
+ return "deleted";
2812
+ if (hasDeliveredReceipt(actor.deliveredChatMessageIds, clientMessageId))
2813
+ return "delivered";
2814
+ if (actor.uncertainChatMessages.has(clientMessageId))
2815
+ return "uncertain";
2816
+ if (actor.pendingChatMessages.has(clientMessageId))
2817
+ return "pending";
2818
+ }
2819
+ if (hasDeliveredReceipt(this.retiredChatDeliveries, clientMessageId))
2820
+ return "delivered";
2821
+ return "unknown";
2822
+ }
2513
2823
  readChatReceipts() {
2514
2824
  const target = this.options.chatReceiptsPath;
2515
2825
  if (target === undefined)
@@ -2677,6 +2987,27 @@ function compactDeliveredReceipts(receipts, now = Date.now()) {
2677
2987
  receipts.delete(oldest);
2678
2988
  }
2679
2989
  }
2990
+ /** 片付けた会話の配送済み receipt の上限(全会話合計)。 */
2991
+ const MAX_RETIRED_CHAT_DELIVERIES = 20_000;
2992
+ function adoptRetiredChatDeliveries(ledger, delivered, now = Date.now()) {
2993
+ for (const [clientMessageId, deliveredAt] of delivered) {
2994
+ ledger.delete(clientMessageId);
2995
+ ledger.set(clientMessageId, deliveredAt);
2996
+ }
2997
+ compactRetiredChatDeliveries(ledger, now);
2998
+ }
2999
+ function compactRetiredChatDeliveries(ledger, now = Date.now()) {
3000
+ for (const [id, deliveredAt] of ledger) {
3001
+ if (now - deliveredAt > DELIVERED_RECEIPT_TTL_MS)
3002
+ ledger.delete(id);
3003
+ }
3004
+ while (ledger.size > MAX_RETIRED_CHAT_DELIVERIES) {
3005
+ const oldest = ledger.keys().next().value;
3006
+ if (oldest === undefined)
3007
+ break;
3008
+ ledger.delete(oldest);
3009
+ }
3010
+ }
2680
3011
  function removeOrderedID(order, id) {
2681
3012
  const index = order.indexOf(id);
2682
3013
  if (index >= 0)