tailii-host 0.5.0 → 0.7.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 (123) hide show
  1. package/dist/approval/hook.js +11 -1
  2. package/dist/approval/hook.js.map +1 -1
  3. package/dist/backend/herdr.d.ts +60 -6
  4. package/dist/backend/herdr.js +180 -27
  5. package/dist/backend/herdr.js.map +1 -1
  6. package/dist/backend/sessionBackend.d.ts +22 -3
  7. package/dist/backend/sessionBackend.js +11 -2
  8. package/dist/backend/sessionBackend.js.map +1 -1
  9. package/dist/backend/tmux.d.ts +197 -3
  10. package/dist/backend/tmux.js +565 -16
  11. package/dist/backend/tmux.js.map +1 -1
  12. package/dist/chat/chatTailController.d.ts +4 -2
  13. package/dist/chat/chatTailController.js +19 -33
  14. package/dist/chat/chatTailController.js.map +1 -1
  15. package/dist/chat/imageService.d.ts +9 -1
  16. package/dist/chat/imageService.js +39 -0
  17. package/dist/chat/imageService.js.map +1 -1
  18. package/dist/chat/subagentTailer.d.ts +10 -0
  19. package/dist/chat/subagentTailer.js +234 -51
  20. package/dist/chat/subagentTailer.js.map +1 -1
  21. package/dist/chat/subagentTranscript.js +51 -5
  22. package/dist/chat/subagentTranscript.js.map +1 -1
  23. package/dist/chat/transcriptTailer.d.ts +53 -1
  24. package/dist/chat/transcriptTailer.js +313 -2
  25. package/dist/chat/transcriptTailer.js.map +1 -1
  26. package/dist/codex/codexAppServer.d.ts +54 -9
  27. package/dist/codex/codexAppServer.js +495 -76
  28. package/dist/codex/codexAppServer.js.map +1 -1
  29. package/dist/codex/codexNativeTurnController.d.ts +22 -1
  30. package/dist/codex/codexNativeTurnController.js +76 -11
  31. package/dist/codex/codexNativeTurnController.js.map +1 -1
  32. package/dist/codex/codexRolloutTailer.d.ts +3 -0
  33. package/dist/codex/codexRolloutTailer.js +85 -13
  34. package/dist/codex/codexRolloutTailer.js.map +1 -1
  35. package/dist/codex/codexSessionStore.js +54 -16
  36. package/dist/codex/codexSessionStore.js.map +1 -1
  37. package/dist/codex/codexSubagentTracker.d.ts +13 -0
  38. package/dist/codex/codexSubagentTracker.js +55 -1
  39. package/dist/codex/codexSubagentTracker.js.map +1 -1
  40. package/dist/codex/codexSystemNotice.d.ts +18 -0
  41. package/dist/codex/codexSystemNotice.js +212 -0
  42. package/dist/codex/codexSystemNotice.js.map +1 -0
  43. package/dist/commands/launch.js +50 -13
  44. package/dist/commands/launch.js.map +1 -1
  45. package/dist/engine/context.d.ts +32 -1
  46. package/dist/engine/context.js +39 -3
  47. package/dist/engine/context.js.map +1 -1
  48. package/dist/engine/engine.d.ts +5 -1
  49. package/dist/engine/engine.js +87 -4
  50. package/dist/engine/engine.js.map +1 -1
  51. package/dist/engine/handlers/accountUsage.d.ts +17 -0
  52. package/dist/engine/handlers/accountUsage.js +40 -6
  53. package/dist/engine/handlers/accountUsage.js.map +1 -1
  54. package/dist/engine/handlers/codex.js +37 -0
  55. package/dist/engine/handlers/codex.js.map +1 -1
  56. package/dist/engine/handlers/core.js +14 -1
  57. package/dist/engine/handlers/core.js.map +1 -1
  58. package/dist/engine/handlers/mode.js +105 -1
  59. package/dist/engine/handlers/mode.js.map +1 -1
  60. package/dist/engine/handlers/session.js +17 -33
  61. package/dist/engine/handlers/session.js.map +1 -1
  62. package/dist/engine/handlers/usage.js +4 -1
  63. package/dist/engine/handlers/usage.js.map +1 -1
  64. package/dist/engine/handlers/workspace.js +22 -1
  65. package/dist/engine/handlers/workspace.js.map +1 -1
  66. package/dist/hub/effortDialog.d.ts +15 -0
  67. package/dist/hub/effortDialog.js +60 -16
  68. package/dist/hub/effortDialog.js.map +1 -1
  69. package/dist/hub/engineRelaySocket.d.ts +7 -0
  70. package/dist/hub/engineRelaySocket.js +7 -1
  71. package/dist/hub/engineRelaySocket.js.map +1 -1
  72. package/dist/hub/hubDaemon.js +30 -5
  73. package/dist/hub/hubDaemon.js.map +1 -1
  74. package/dist/hub/hubProtocol.d.ts +10 -0
  75. package/dist/hub/hubProtocol.js +19 -2
  76. package/dist/hub/hubProtocol.js.map +1 -1
  77. package/dist/hub/panePreviewPump.d.ts +27 -0
  78. package/dist/hub/panePreviewPump.js +62 -1
  79. package/dist/hub/panePreviewPump.js.map +1 -1
  80. package/dist/hub/sessionHub.d.ts +125 -4
  81. package/dist/hub/sessionHub.js +413 -48
  82. package/dist/hub/sessionHub.js.map +1 -1
  83. package/dist/protocol/decode.js +84 -15
  84. package/dist/protocol/decode.js.map +1 -1
  85. package/dist/protocol/encode.js +2 -0
  86. package/dist/protocol/encode.js.map +1 -1
  87. package/dist/protocol/messages.d.ts +87 -1
  88. package/dist/services/accountIdentity.js +3 -1
  89. package/dist/services/accountIdentity.js.map +1 -1
  90. package/dist/services/fileService.d.ts +15 -0
  91. package/dist/services/fileService.js +176 -14
  92. package/dist/services/fileService.js.map +1 -1
  93. package/dist/services/officialApps.d.ts +21 -1
  94. package/dist/services/officialApps.js +201 -53
  95. package/dist/services/officialApps.js.map +1 -1
  96. package/dist/services/planUsageFetcher.d.ts +35 -3
  97. package/dist/services/planUsageFetcher.js +34 -6
  98. package/dist/services/planUsageFetcher.js.map +1 -1
  99. package/dist/services/previewServer.js +15 -6
  100. package/dist/services/previewServer.js.map +1 -1
  101. package/dist/sessions/claudeSessionStore.js +30 -0
  102. package/dist/sessions/claudeSessionStore.js.map +1 -1
  103. package/dist/sessions/heartbeat.d.ts +7 -1
  104. package/dist/sessions/heartbeat.js +3 -1
  105. package/dist/sessions/heartbeat.js.map +1 -1
  106. package/dist/sessions/sessionSearch.js +23 -5
  107. package/dist/sessions/sessionSearch.js.map +1 -1
  108. package/dist/sessions/transcriptPath.d.ts +3 -0
  109. package/dist/sessions/transcriptPath.js +15 -0
  110. package/dist/sessions/transcriptPath.js.map +1 -0
  111. package/dist/shared/clientBuild.d.ts +26 -0
  112. package/dist/shared/clientBuild.js +89 -0
  113. package/dist/shared/clientBuild.js.map +1 -0
  114. package/dist/shared/crossSession.d.ts +15 -0
  115. package/dist/shared/crossSession.js +75 -0
  116. package/dist/shared/crossSession.js.map +1 -0
  117. package/dist/shared/harnessReminder.d.ts +32 -0
  118. package/dist/shared/harnessReminder.js +124 -0
  119. package/dist/shared/harnessReminder.js.map +1 -0
  120. package/dist/shared/systemNotice.d.ts +30 -0
  121. package/dist/shared/systemNotice.js +209 -0
  122. package/dist/shared/systemNotice.js.map +1 -0
  123. package/package.json +3 -3
@@ -6,11 +6,13 @@ 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
8
  import { decodeHubClientLine, encodeHubMessage, } from "./hubProtocol.js";
9
- import { HISTORY_DONE_STREAM_ID } from "../chat/transcriptTailer.js";
9
+ import { HISTORY_DONE_STREAM_ID, findTrailingTurnEndMarkerMs, findTrailingUserPromptText, } from "../chat/transcriptTailer.js";
10
10
  import { PROTOCOL_V1, PROTOCOL_V2 } from "../protocol.js";
11
+ import { LoginCodeError } from "../backend/tmux.js";
11
12
  import { CodexAppServerManager } from "../codex/codexAppServer.js";
12
13
  import { CodexNativeTurnController, chatContentKey, } from "../codex/codexNativeTurnController.js";
13
14
  import { 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";
15
+ import { claudeTranscriptPathFor } from "../sessions/transcriptPath.js";
14
16
  /**
15
17
  * backfilling 中の購読者は publishConversationEvent が丸ごと飛ばす。旗が立ちっぱなしになると
16
18
  * 「接続は健全なのに会話だけ永久に更新されない」実障害になるため、履歴が一定時間 1 行も進まな
@@ -20,6 +22,23 @@ import { CONTEXT_STREAM_ID as CODEX_CONTEXT_STREAM_ID, CONTEXT_WINDOW_STREAM_ID
20
22
  const BACKFILL_STALL_TIMEOUT_MS = 15_000;
21
23
  const DELIVERED_RECEIPT_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
22
24
  const MAX_DELIVERED_RECEIPTS_PER_SESSION = 10_000;
25
+ /** 中断マーカーで done にした後、継続 hook(Pre/PostToolUse)の遅着 active を残響として無視する窓(ms)。 */
26
+ const LATE_HOOK_AFTER_INTERRUPT_MS = 3_000;
27
+ /**
28
+ * 発火時刻付き hook を「既に終わったターンの遅着」とみなす古さの上限(ms)。hook は発火から
29
+ * 100〜300ms(高負荷でも数秒)で relay に届くので、終端マーカーよりそれ以上前に発火した hook が
30
+ * マーカーの後に届くことは無い。上限を切らないと、queued 発話の UserPromptSubmit が enqueue 時に
31
+ * 発火する版が現れた場合に、数秒後の dequeue で始まる生きたターンを idle に固定してしまう。
32
+ */
33
+ const LATE_HOOK_MAX_AGE_MS = 3_000;
34
+ /** ターンを始めない継続 hook か(新ターンの権威は UserPromptSubmit のみ)。 */
35
+ function isContinuationHookEvent(event) {
36
+ return event === "PreToolUse" || event === "PostToolUse";
37
+ }
38
+ /** 既定の claude transcript 解決(sessions/transcriptPath.ts と同一規則)。 */
39
+ function defaultTranscriptPathFor(meta) {
40
+ return claudeTranscriptPathFor(meta);
41
+ }
23
42
  export class SessionHub {
24
43
  options;
25
44
  clients = new Map();
@@ -27,6 +46,7 @@ export class SessionHub {
27
46
  previewWatchers = new Set();
28
47
  actors = new Map();
29
48
  now;
49
+ nowMs;
30
50
  replayLimit;
31
51
  injectionsInFlight = 0;
32
52
  codexStartsInFlight = 0;
@@ -37,6 +57,7 @@ export class SessionHub {
37
57
  constructor(options) {
38
58
  this.options = options;
39
59
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
60
+ this.nowMs = options.nowMs ?? (() => Date.now());
40
61
  this.replayLimit = options.replayLimit ?? 500;
41
62
  }
42
63
  get clientCount() { return this.clients.size; }
@@ -68,9 +89,68 @@ export class SessionHub {
68
89
  // (稼働中の hub は毎 tick bump しているので、直前まで生きていた heartbeat は必ず新しい)。
69
90
  if (this.now() - heartbeat.ts >= this.options.timeoutSeconds)
70
91
  continue;
71
- this.actor(session).processingSince = heartbeat.ts;
92
+ // 再起動前に利用者が中断して放置した会話は Stop hook が無く active のまま残る。engine の
93
+ // 再接続購読は newerThanMs 付きで履歴のマーカーを流さないため、tail 経由では二度と
94
+ // 観測できない。transcript 末尾を直接照合し、最後の発話が開始以降の中断マーカーなら
95
+ // 処理中に戻さず idle へ確定する。
96
+ const trailingInterruptMs = this.trailingInterruptMarkerMs(session);
97
+ if (trailingInterruptMs !== null && (heartbeat.sinceMs === undefined || trailingInterruptMs >= heartbeat.sinceMs)) {
98
+ // 再起動中に hook が新ターンの active を書いた直後なら上書きしない(読んだ内容と同じときだけ倒す)。
99
+ const latest = readHeartbeat(this.options.heartbeatDir, session);
100
+ const unchanged = latest !== null && latest.ts === heartbeat.ts && latest.state === heartbeat.state &&
101
+ latest.event === heartbeat.event && latest.sinceMs === heartbeat.sinceMs;
102
+ if (!unchanged) {
103
+ // 消えていた(kill 後の掃除)場合も再生成しない。
104
+ this.options.log?.(`restore: heartbeat が更新されたため中断判定を見送る session=${session}`);
105
+ }
106
+ else {
107
+ try {
108
+ writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: "idle", event: "restore-interrupted" });
109
+ }
110
+ catch (error) {
111
+ this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
112
+ }
113
+ this.options.log?.(`restore: 中断済みのため処理中を復元しない session=${session}`);
114
+ continue;
115
+ }
116
+ }
117
+ const actor = this.actor(session);
118
+ actor.processingSince = heartbeat.ts;
119
+ // 真の開始時刻は heartbeat の sinceMs(applyProcessing が書き、bump が保持する)。ts は
120
+ // 毎 tick bump されるので使えない。無ければ(hook 直書きの heartbeat・旧形式)復元時刻を
121
+ // 境界にする: 再起動前の中断は上の transcript 照合が担い、再起動後の中断は必ずこれより
122
+ // 新しい。null(全採用)にすると、開き直しの全履歴再生で流れる過去ターンのマーカーが
123
+ // 進行中ターンを落とす。
124
+ actor.processingSinceMs = heartbeat.sinceMs ?? this.nowMs();
72
125
  }
73
126
  }
127
+ /**
128
+ * 復元時の照合用: transcript 末尾のターン終端マーカー(中断確定 / API エラー終端)の timestamp
129
+ * (claude 会話のみ。不明は null)。
130
+ */
131
+ trailingInterruptMarkerMs(session) {
132
+ const meta = this.options.metadataStore.get(session);
133
+ if (meta === null || meta.agent === "codex")
134
+ return null;
135
+ const transcriptPath = (this.options.transcriptPathFor ?? defaultTranscriptPathFor)(meta);
136
+ if (transcriptPath === null)
137
+ return null;
138
+ return findTrailingTurnEndMarkerMs(transcriptPath);
139
+ }
140
+ /**
141
+ * chat 注入前の残存テキスト照合用: transcript 末尾に記録済みの直近の発話本文(claude 会話のみ。
142
+ * 不明は null)。中断で入力欄へ書き戻された配送済み発話を Enter で送り直さないための材料
143
+ * (restored-prompt-discard。詳細は `findTrailingUserPromptText`)。
144
+ */
145
+ trailingUserPromptText(session) {
146
+ const meta = this.options.metadataStore.get(session);
147
+ if (meta === null || meta.agent === "codex")
148
+ return null;
149
+ const transcriptPath = (this.options.transcriptPathFor ?? defaultTranscriptPathFor)(meta);
150
+ if (transcriptPath === null)
151
+ return null;
152
+ return findTrailingUserPromptText(transcriptPath);
153
+ }
74
154
  /** 永続化済み設問を復元する。App Server の request handle は再起動を越せないため TUI のみ対象。 */
75
155
  restorePendingQuestions() {
76
156
  const restored = this.readPendingQuestions();
@@ -207,8 +287,10 @@ export class SessionHub {
207
287
  if (message.event === "dismiss")
208
288
  void this.drainChatQueue(message.session, actor);
209
289
  }
210
- else if (message.type === "session_processing")
211
- this.applyProcessing(message.session, message.state);
290
+ else if (message.type === "session_processing") {
291
+ if (!this.applyProcessing(message.session, message.state, message.event, message.atMs))
292
+ return;
293
+ }
212
294
  this.broadcast(message);
213
295
  }
214
296
  handleClientMessage(client, line) {
@@ -553,7 +635,8 @@ export class SessionHub {
553
635
  actor.runtimeClaim = null;
554
636
  return;
555
637
  }
556
- this.applyProcessing(message.session, message.state);
638
+ if (!this.applyProcessing(message.session, message.state, message.event, message.atMs))
639
+ return;
557
640
  this.broadcast(message);
558
641
  }
559
642
  async tick() {
@@ -662,11 +745,18 @@ export class SessionHub {
662
745
  actor.uncertainCodexMessages.clear();
663
746
  actor.deletedCodexMessageIds.clear();
664
747
  actor.pendingQuestion = null;
748
+ const retiredSubscribers = [...actor.subscribers.keys()];
665
749
  actor.focusedBy.clear();
666
750
  actor.subscribers.clear();
667
751
  this.actors.delete(session);
668
752
  this.persistPendingQuestions();
669
753
  this.persistChatReceipts();
754
+ // 購読者(engine)へ購読消滅を知らせる(背景購読の台帳同期。一覧向け liveness とは別経路)。
755
+ // actor を消した後に送る: 受け手が同期的に再購読(前面会話の張り直し)しても、消える直前の
756
+ // 古い actor に付いて一緒に捨てられないように(新しい actor が生成される)。
757
+ for (const subscriber of retiredSubscribers) {
758
+ this.sendTo(subscriber, { type: "conversation_retired", session });
759
+ }
670
760
  }
671
761
  subscribe(client, session, afterSeq, newerThanMs, preview) {
672
762
  const actor = this.actor(session);
@@ -699,6 +789,10 @@ export class SessionHub {
699
789
  else {
700
790
  this.syncPreview(session, actor);
701
791
  }
792
+ // 会話を開き直したとき(pump は一覧 watch で稼働中)、静止した入力待ちダイアログの
793
+ // フレームは変化しないので再送しないと転写カードが出ない。
794
+ if (preview && !wasPreview)
795
+ actor.previewPump?.resendLastIfInteractive?.();
702
796
  // preview=false 中に engine が route できなかった image/subagent event を、同じ
703
797
  // subscriber の前面昇格時にも afterSeq から回収する。既存購読だからと no-op にしない。
704
798
  if (!existing.backfilling && afterSeq !== undefined) {
@@ -723,6 +817,8 @@ export class SessionHub {
723
817
  }
724
818
  // 初回 backfill が同期的に多数の行を生成する前に pane capture を開始する。
725
819
  this.syncPreview(session, actor);
820
+ if (preview)
821
+ actor.previewPump?.resendLastIfInteractive?.();
726
822
  if (first) {
727
823
  this.startSharedTail(session, actor, newerThanMs ?? null);
728
824
  // processing 完了で一度 unsubscribe された後も、actor の replay buffer が残る間は
@@ -798,13 +894,15 @@ export class SessionHub {
798
894
  return;
799
895
  const tail = this.options.tailFactory((payload) => {
800
896
  this.publishConversationEvent(session, actor, payload);
801
- });
897
+ }, undefined, (event) => this.handleClaudeTurnLifecycle(session, actor, event));
802
898
  actor.tail = tail;
803
899
  tail.open(meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, newerThanMs, meta.agent ?? "claude");
804
900
  }
805
901
  startCodexSharedStream(session, actor, cwd, threadId, newerThanMs) {
806
902
  const state = {
807
- phase: "starting",
903
+ phase: "backfill",
904
+ snapshotReady: false,
905
+ historyDone: false,
808
906
  initialItemIds: new Set(),
809
907
  initialContentCounts: new Map(),
810
908
  buffered: [],
@@ -813,12 +911,21 @@ export class SessionHub {
813
911
  scanContentCounts: new Map(),
814
912
  fallbackBaselineCounts: new Map(),
815
913
  disconnected: false,
914
+ lastModel: null,
915
+ subscribedModel: null,
916
+ scanLastModel: null,
816
917
  };
817
918
  actor.codexLive = state;
919
+ // 履歴表示は rollout だけで完結する。App Server の購読(daemon 起動・接続・thread/resume・
920
+ // 履歴スナップショット。巨大 thread では items/list が 3 秒で timeout してから復旧接続へ進む)
921
+ // の完了を待ってから rollout を開くと、その間 iOS は「会話を読み込み中…」のまま止まって
922
+ // 見える。rollout の backfill を先に始め、購読は並行して確立する。境界照合(flushCodexBuffer)
923
+ // は history-done と snapshot の両方が揃った時点で行う(順序不定)。
924
+ this.openCodexRollout(session, actor, cwd, threadId, newerThanMs);
818
925
  const controller = this.ensureCodexTurnController();
819
926
  const subscribe = controller.subscribeSession;
820
927
  if (subscribe === undefined) {
821
- this.startCodexFallback(session, actor, cwd, threadId, newerThanMs, false);
928
+ this.degradeCodexToFallback(session, actor, state, null);
822
929
  return;
823
930
  }
824
931
  void subscribe.call(controller, { session, threadId, cwd }).then((snapshot) => {
@@ -826,20 +933,61 @@ export class SessionHub {
826
933
  return;
827
934
  state.initialItemIds = snapshot.itemIds;
828
935
  state.initialContentCounts = snapshot.contentCounts;
936
+ // 会話を開いていない間の /model や設定変更は thread/settings/updated が誰にも届かない。
937
+ // resume 応答の現在モデルを backfill 完了後に配信し、開き直しで表示を実体へ揃える。
938
+ state.subscribedModel = snapshot.model ?? null;
829
939
  if (!snapshot.liveSubscribed) {
830
- this.options.log?.("Codex App Server は未materialize threadをlive購読できないため、rollout fallbackへ移行");
831
- this.startCodexFallback(session, actor, cwd, threadId, newerThanMs, false);
940
+ this.options.log?.(`Codex App Server の履歴スナップショットが読めない(turn 前の未materialize か履歴読み取り失敗)ため、rollout fallbackへ移行 session=${session} thread=${threadId}` +
941
+ (snapshot.liveSubscriptionError ? `(理由: ${snapshot.liveSubscriptionError})` : ""));
942
+ this.degradeCodexToFallback(session, actor, state, null);
832
943
  return;
833
944
  }
834
- state.phase = "backfill";
835
- this.openCodexRollout(session, actor, cwd, threadId, newerThanMs);
945
+ state.snapshotReady = true;
946
+ if (state.historyDone)
947
+ this.finishCodexBackfill(session, actor, state);
836
948
  }, (error) => {
837
949
  if (actor.codexLive !== state || actor.subscribers.size === 0)
838
950
  return;
839
- this.options.log?.(`Codex App Server 購読失敗、rollout fallback へ移行: ${String(error)}`);
840
- this.startCodexFallback(session, actor, cwd, threadId, newerThanMs, false);
951
+ this.degradeCodexToFallback(session, actor, state, `Codex App Server 購読失敗、rollout fallback へ移行: ${String(error)}`);
841
952
  });
842
953
  }
954
+ /**
955
+ * 購読が成立しない(未materialize・履歴読み取り失敗・購読エラー)と分かった時点で、既に開いて
956
+ * いる rollout tail をそのまま一次ソースに降格する。tail を開き直すと履歴を頭から二重再生する
957
+ * ため再利用する(接続断と同じ扱い: history-done で fallback-live へ、到達済みなら即座に)。
958
+ */
959
+ degradeCodexToFallback(session, actor, state, logMessage) {
960
+ if (logMessage !== null)
961
+ this.options.log?.(logMessage);
962
+ state.disconnected = true;
963
+ state.buffered.length = 0;
964
+ if (state.phase === "backfill" && state.historyDone)
965
+ this.finishCodexBackfill(session, actor, state);
966
+ }
967
+ /** backfill 完了(history-done)と購読の確定(snapshot または fallback)が揃った時点の切り替え。 */
968
+ finishCodexBackfill(session, actor, state) {
969
+ // 履歴末尾の turn_context より新しい設定変更(turn 未実行)は rollout に無い。
970
+ // 購読時の thread モデルで上書きする(同値なら畳む)。fallback でも resume が
971
+ // 成立していれば同じ。
972
+ const subscribedModel = state.subscribedModel;
973
+ state.subscribedModel = null;
974
+ if (state.disconnected) {
975
+ // fallback 確定前に届いた App Server item は、この継続 rollout と同じ内容を
976
+ // 別 streamId で持ち得る。rollout を唯一の一次ソースにした時点で破棄し、
977
+ // history 完了時に flush して会話全体を二重表示しない。
978
+ state.buffered.length = 0;
979
+ state.phase = "fallback-live"; // この tail は既に同じ EOF 境界にいるので継続利用する。
980
+ }
981
+ else {
982
+ this.flushCodexBuffer(session, actor, state);
983
+ state.phase = "live";
984
+ // 本文は以後 App Server のみを採用するが、rollout tail 自体は lifecycle の
985
+ // 副監視として残す。ここで止めると、後続 turn の App Server `turn/completed`
986
+ // 欠落時に terminal event(task_complete / turn_aborted)を観測できず、
987
+ // 処理中状態を自己修復できない。
988
+ }
989
+ this.publishCodexModelMarker(session, actor, state, subscribedModel);
990
+ }
843
991
  openCodexRollout(session, actor, cwd, threadId, newerThanMs) {
844
992
  if (this.options.tailFactory === undefined)
845
993
  return;
@@ -852,11 +1000,27 @@ export class SessionHub {
852
1000
  return;
853
1001
  const isHistoryDone = payload.type === "chat_output" && payload.streamId === HISTORY_DONE_STREAM_ID;
854
1002
  const contentKey = chatContentKey(payload);
1003
+ const isModelMarker = payload.type === "chat_output" && payload.streamId === CODEX_MODEL_STREAM_ID;
1004
+ // rollout の turn_context は「その turn で実際に使ったモデル」の唯一の記録。live 中も
1005
+ // fallback 中も本文とは独立に通し、App Server 通知(onModel)と同値なら畳む。
1006
+ // token 系 marker は App Server の tokenUsage と意味が異なるため従来どおり混ぜない。
1007
+ if (isModelMarker && state.phase !== "backfill") {
1008
+ if (state.phase === "fallback-scan") {
1009
+ // 再走査は履歴を頭から読み直す。旧 turn のモデルを一度配って戻すのではなく、
1010
+ // 走査完了時に最後の値だけを配る。
1011
+ state.scanLastModel = payload.text;
1012
+ return;
1013
+ }
1014
+ this.publishCodexModelMarker(session, actor, state, payload.text);
1015
+ return;
1016
+ }
855
1017
  if (state.phase === "live")
856
1018
  return; // 正常時の live 本文は App Server のみ。
857
1019
  if (state.phase === "fallback-scan") {
858
1020
  if (isHistoryDone) {
859
1021
  state.phase = "fallback-live";
1022
+ this.publishCodexModelMarker(session, actor, state, state.scanLastModel);
1023
+ state.scanLastModel = null;
860
1024
  return; // 通常 backfill の pc:history-done を再送しない。
861
1025
  }
862
1026
  if (contentKey === null)
@@ -873,30 +1037,22 @@ export class SessionHub {
873
1037
  this.publishCodexContent(session, actor, state, payload);
874
1038
  return;
875
1039
  }
876
- // App Server 購読を先に確立して通知を buffer し、その後 rollout を EOF まで読む。
1040
+ // rollout を EOF まで読みつつ、App Server 購読は並行して確立し通知を buffer する。
877
1041
  // resume 応答の item ID / content occurrence を境界スナップショットとして使い、
878
- // EOF 後は rollout にまだ無い buffered item だけを flush する。event_msg 自体には
879
- // item ID が無いため、同文は Set ではなく occurrence count で照合する。
1042
+ // 両方が揃った時点で rollout にまだ無い buffered item だけを flush する。event_msg
1043
+ // 自体には item ID が無いため、同文は Set ではなく occurrence count で照合する。
1044
+ // snapshot がまだなら backfill のまま留まり、EOF 以降に rollout へ追記された行も
1045
+ // この経路で配信する(snapshot 側の occurrence と後で照合されるので重複しない)。
880
1046
  this.publishConversationEvent(session, actor, payload);
881
1047
  if (contentKey !== null)
882
1048
  incrementCount(state.publishedContentCounts, contentKey);
1049
+ if (isModelMarker)
1050
+ state.lastModel = payload.text;
883
1051
  if (!isHistoryDone)
884
1052
  return;
885
- if (state.disconnected) {
886
- // fallback 確定前に届いた App Server item は、この継続 rollout と同じ内容を
887
- // 別 streamId で持ち得る。rollout を唯一の一次ソースにした時点で破棄し、
888
- // history 完了時に flush して会話全体を二重表示しない。
889
- state.buffered.length = 0;
890
- state.phase = "fallback-live"; // この tail は既に同じ EOF 境界にいるので継続利用する。
891
- }
892
- else {
893
- this.flushCodexBuffer(session, actor, state);
894
- state.phase = "live";
895
- // 本文は以後 App Server のみを採用するが、rollout tail 自体は lifecycle の
896
- // 副監視として残す。ここで止めると、後続 turn の App Server `turn/completed`
897
- // 欠落時に terminal event(task_complete / turn_aborted)を観測できず、
898
- // 処理中状態を自己修復できない。
899
- }
1053
+ state.historyDone = true;
1054
+ if (state.snapshotReady || state.disconnected)
1055
+ this.finishCodexBackfill(session, actor, state);
900
1056
  }, (event) => {
901
1057
  if (actor.codexLive !== state || event.state !== "done")
902
1058
  return;
@@ -920,7 +1076,7 @@ export class SessionHub {
920
1076
  // 一次ソースにし、運良く届いた App Server item を backfill buffer へ混ぜて重複させない。
921
1077
  if (state.disconnected)
922
1078
  return;
923
- if (state.phase === "starting" || state.phase === "backfill") {
1079
+ if (state.phase === "backfill") {
924
1080
  // tool_activity は buffer せず捨てる。履歴は rollout(backfill)側が同じカードを
925
1081
  // 供給するのが正で、live 由来と rollout 由来のカードは id が一致しないため
926
1082
  // snapshot occurrence 照合に混ぜると開くたびに重複しうる。backfill 中に完了した
@@ -984,26 +1140,32 @@ export class SessionHub {
984
1140
  const state = actor?.codexLive;
985
1141
  if (actor === undefined || state == null || actor.subscribers.size === 0)
986
1142
  return;
987
- this.options.log?.(`Codex App Server 接続断、rollout fallback へ移行: ${String(error)}`);
988
- state.disconnected = true;
989
- if (state.phase === "starting" || state.phase === "backfill")
1143
+ const logMessage = `Codex App Server 接続断、rollout fallback へ移行: ${String(error)}`;
1144
+ if (state.phase === "backfill") {
1145
+ // backfill 中(snapshot 待ちを含む)の切断は、開いている rollout tail をそのまま一次ソースにする。
1146
+ this.degradeCodexToFallback(session, actor, state, logMessage);
990
1147
  return;
1148
+ }
1149
+ this.options.log?.(logMessage);
1150
+ state.disconnected = true;
991
1151
  if (state.phase !== "live")
992
1152
  return;
993
1153
  const meta = this.options.metadataStore.get(session);
994
1154
  if (meta === null)
995
1155
  return;
996
- this.startCodexFallback(session, actor, meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, null, true);
1156
+ this.startCodexFallbackRescan(session, actor, meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, null);
997
1157
  }
998
- startCodexFallback(session, actor, cwd, threadId, newerThanMs, rescan) {
1158
+ /** live 移行後の接続断: rollout を頭から再走査し、配信済み本文を occurrence 照合で除外して継続する。 */
1159
+ startCodexFallbackRescan(session, actor, cwd, threadId, newerThanMs) {
999
1160
  const state = actor.codexLive;
1000
1161
  if (state === null)
1001
1162
  return;
1002
1163
  actor.tail?.stop();
1003
- state.phase = rescan ? "fallback-scan" : "backfill";
1164
+ state.phase = "fallback-scan";
1004
1165
  state.disconnected = true;
1005
1166
  state.buffered.length = 0;
1006
1167
  state.scanContentCounts.clear();
1168
+ state.scanLastModel = null;
1007
1169
  state.fallbackBaselineCounts = new Map(state.publishedContentCounts);
1008
1170
  this.openCodexRollout(session, actor, cwd, threadId, newerThanMs);
1009
1171
  }
@@ -1012,6 +1174,16 @@ export class SessionHub {
1012
1174
  if (key !== null)
1013
1175
  incrementCount(state.publishedContentCounts, key);
1014
1176
  this.publishConversationEvent(session, actor, payload);
1177
+ if (payload.type === "chat_output" && payload.role === "user" && this.options.imageService !== undefined) {
1178
+ // 本文配信は止めず、完成したサムネを同じ streamId へ後着させる。
1179
+ void this.options.imageService.makeAttachmentsAvailable(payload.text, payload.streamId).then((images) => {
1180
+ // サムネ生成中の離脱/再購読/actor 破棄で、古い会話へ届かせない。
1181
+ if (this.actors.get(session) !== actor || actor.codexLive !== state || actor.subscribers.size === 0)
1182
+ return;
1183
+ for (const image of images)
1184
+ this.publishConversationEvent(session, actor, image);
1185
+ }, (error) => this.options.log?.(`Codex 添付サムネ生成失敗 session=${session}: ${String(error)}`));
1186
+ }
1015
1187
  }
1016
1188
  publishConversationEvent(session, actor, payload) {
1017
1189
  const event = { serverSeq: actor.nextServerSeq++, payload };
@@ -1068,6 +1240,13 @@ export class SessionHub {
1068
1240
  ownState.backfilling = false;
1069
1241
  if (reason !== "history-done") {
1070
1242
  this.options.log?.(`audit backfill-force-finish session=${session} reason=${reason}`);
1243
+ if (meta.agent === "codex") {
1244
+ this.sendTo(client, {
1245
+ type: "conversation_event", session, serverSeq: 0,
1246
+ payload: { type: "chat_output", v: PROTOCOL_V1,
1247
+ streamId: "pc:history-cancelled", role: "system", text: "", eof: true },
1248
+ });
1249
+ }
1071
1250
  }
1072
1251
  if (actor.codexLive !== null) {
1073
1252
  // codex は rollout(履歴) と app-server(live) が同じ発話を二重に表すため、履歴側が
@@ -1101,6 +1280,14 @@ export class SessionHub {
1101
1280
  this.sendTo(client, { type: "conversation_event", session, ...event });
1102
1281
  }
1103
1282
  }
1283
+ // 後から加わった client の履歴には、turn を伴わない設定変更(境界前に配信済みの
1284
+ // thread/settings/updated)が含まれない。会話の現在モデルを最後に 1 回添えて揃える。
1285
+ const currentModel = actor.codexLive?.lastModel ?? null;
1286
+ if (currentModel !== null) {
1287
+ this.sendTo(client, {
1288
+ type: "conversation_event", session, serverSeq: 0, payload: codexModelMarker(currentModel),
1289
+ });
1290
+ }
1104
1291
  };
1105
1292
  // 進捗は「最後に 1 行流れた時刻」で測る。履歴行ごとに setTimeout を張り替えると
1106
1293
  // 巨大な transcript でタイマー生成が支配的になるため、タイマーは 1 本のまま残時間を見る。
@@ -1220,7 +1407,9 @@ export class SessionHub {
1220
1407
  actor(session) {
1221
1408
  let actor = this.actors.get(session);
1222
1409
  if (actor === undefined) {
1223
- actor = { pendingQuestion: null, processingSince: null, focusedBy: new Set(), subscribers: new Map(),
1410
+ actor = { pendingQuestion: null, processingSince: null, processingSinceMs: null, lastInterruptDoneMs: null,
1411
+ lastTurnEndMs: null, turnStartFiredAtMs: null, lastTurnStartMs: null,
1412
+ focusedBy: new Set(), subscribers: new Map(),
1224
1413
  nextServerSeq: 1, replayBuffer: [], tail: null, tailRetryTimer: null,
1225
1414
  previewPump: null, backfillTails: new Map(),
1226
1415
  seenClientMessageIds: new Set(), deliveredChatMessageIds: new Map(),
@@ -1402,7 +1591,9 @@ export class SessionHub {
1402
1591
  }
1403
1592
  continue;
1404
1593
  }
1405
- await (this.options.chatInjector?.(message.text, session) ?? Promise.resolve());
1594
+ await (this.options.chatInjector?.(message.text, session, {
1595
+ recordedPromptText: () => this.trailingUserPromptText(session),
1596
+ }) ?? Promise.resolve());
1406
1597
  // 明示 kill / reaper kill が注入 await 中に actor を廃棄した場合、同名の新 actorへ
1407
1598
  // 古い receipt を復活させない。waiter は retireSession が既に失敗で解放している。
1408
1599
  if (this.actors.get(session) !== actor)
@@ -1428,14 +1619,25 @@ export class SessionHub {
1428
1619
  continue;
1429
1620
  actor.injectingChatMessageIds.delete(message.clientMessageId);
1430
1621
  actor.pendingChatMessages.delete(message.clientMessageId);
1431
- actor.uncertainChatMessages.set(message.clientMessageId, message);
1622
+ // `/login` 中の門番(LoginCodeError)は 1 キーも送る前の確定拒否 = 非配達が確定している。
1623
+ // uncertain(配送不明)に積むと削除不能・後続ブロックのゾンビになるので、失敗として
1624
+ // そのまま片付ける。それ以外の注入失敗は従来どおり配送不明として明示再送に倒す。
1625
+ const rejectedBeforeSend = error instanceof LoginCodeError;
1626
+ if (rejectedBeforeSend) {
1627
+ removeOrderedID(actor.chatOrder, message.clientMessageId);
1628
+ }
1629
+ else {
1630
+ actor.uncertainChatMessages.set(message.clientMessageId, message);
1631
+ }
1432
1632
  this.persistChatReceipts();
1633
+ // 利用者向け文言だけを出す(`String(error)` は `LoginCodeError: …` と型名が前置される)。
1634
+ const userText = rejectedBeforeSend && error instanceof Error ? error.message : String(error);
1433
1635
  for (const waiter of entry.waiters) {
1434
1636
  this.sendTo(waiter.client, {
1435
- type: "chat_send_result", id: waiter.id, status: "failed", error: String(error),
1637
+ type: "chat_send_result", id: waiter.id, status: "failed", error: userText,
1436
1638
  });
1437
1639
  }
1438
- this.publishCodexMarker(session, `chat-send-error-${message.id}`, `⚠️ メッセージ送信失敗: ${String(error)}`);
1640
+ this.publishCodexMarker(session, `chat-send-error-${message.id}`, `⚠️ メッセージ送信失敗: ${userText}`);
1439
1641
  this.options.log?.(`chat 注入失敗 session=${session}: ${String(error)}`);
1440
1642
  }
1441
1643
  finally {
@@ -1454,11 +1656,13 @@ export class SessionHub {
1454
1656
  ensureCodexTurnController() {
1455
1657
  if (this.codexTurnController !== null)
1456
1658
  return this.codexTurnController;
1457
- const appServer = (this.options.codexAppServerFactory ?? (() => new CodexAppServerManager()))();
1659
+ const appServer = (this.options.codexAppServerFactory ??
1660
+ (() => new CodexAppServerManager({ ...(this.options.log ? { log: this.options.log } : {}) })))();
1458
1661
  const create = this.options.codexTurnControllerFactory ??
1459
1662
  ((options) => new CodexNativeTurnController(options));
1460
1663
  this.codexTurnController = create({
1461
1664
  appServer,
1665
+ log: (message) => this.options.log?.(message),
1462
1666
  onProcessing: (session, state) => {
1463
1667
  if (state === "active")
1464
1668
  this.activeCodexTurns.add(session);
@@ -1596,18 +1800,171 @@ export class SessionHub {
1596
1800
  }
1597
1801
  publishCodexMarker(session, streamId, text) {
1598
1802
  const actor = this.actor(session);
1803
+ if (streamId === CODEX_MODEL_STREAM_ID && actor.codexLive !== null) {
1804
+ this.publishCodexModelMarker(session, actor, actor.codexLive, text);
1805
+ return;
1806
+ }
1599
1807
  this.publishConversationEvent(session, actor, {
1600
1808
  type: "chat_output", v: PROTOCOL_V1, streamId, role: "system", text, eof: true,
1601
1809
  });
1602
1810
  }
1603
- applyProcessing(session, state) {
1811
+ /**
1812
+ * 利用中モデルの marker を、直前に配信した値と異なるときだけ配信する。App Server 通知
1813
+ * (thread/settings/updated / model/rerouted)・rollout の turn_context・購読時の
1814
+ * thread モデルは同じ値を別経路で運ぶため、ここで 1 本に畳む。
1815
+ */
1816
+ publishCodexModelMarker(session, actor, state, model) {
1817
+ if (model === null || model.length === 0 || state.lastModel === model)
1818
+ return;
1819
+ state.lastModel = model;
1820
+ this.publishConversationEvent(session, actor, codexModelMarker(model));
1821
+ }
1822
+ /**
1823
+ * transcript の中断確定マーカーで処理完了を補完する。Claude Code の Stop hook は利用者の中断
1824
+ * (Esc / Ctrl-C)では発火しないため、hook だけでは処理中状態がターン終了後も残り、iOS は
1825
+ * 会話を開き直す・再接続するたびに停止ボタン/処理中表示を張り直してしまう(実機 09-02)。
1826
+ * 共有 tail は購読の張り直しで履歴から再生されるため、マーカーの timestamp が現ターンの
1827
+ * 処理開始時刻以降のものだけを採用する(過去ターンのマーカーで進行中ターンを落とさない)。
1828
+ *
1829
+ * 時刻の比較根拠: `processingSinceMs` は UserPromptSubmit hook を hub が受信した時刻(ターン
1830
+ * 開始。継続 hook では進めない)。中断→即再送信(実測 10〜200ms 後)でも、新ターンの hook
1831
+ * 受信は必ずマーカー書込より後(同一マシンの時計・因果順)なので、マーカーは新ターンの開始
1832
+ * より古く見えて棄却される。tail がマーカーを hook より先に届けた場合は前ターンを done に
1833
+ * し、直後の UserPromptSubmit で active に戻る。中断直後に遅着する Pre/PostToolUse の active
1834
+ * は `applyProcessing` が残響として無視する。
1835
+ *
1836
+ * 副作用は hook の Stop と同じ `applyProcessing("done")` 一式(heartbeat idle。設問提示中なら
1837
+ * その dismiss と chat queue の drain)。中断はターン終了なので Stop 後と同じ振る舞い。
1838
+ */
1839
+ handleClaudeTurnLifecycle(session, actor, event) {
1840
+ // API エラー終端(使用量上限 429「You've hit your session limit」等)も中断と同じ扱い: Stop hook が
1841
+ // 発火しないままターンが終わるため、hub の処理中状態がそのまま残る(実機 2026-09-07: 制限到達後の
1842
+ // 会話が停止ボタン点灯のまま固まり、開き直しても履歴再生が走らず送信が queued で止まった)。
1843
+ if (this.actors.get(session) !== actor)
1844
+ return;
1845
+ if (this.options.metadataStore.get(session)?.agent === "codex")
1846
+ return;
1847
+ if (event.kind === "turn_start") {
1848
+ // 発話の観測(履歴再生も含めて最新値を保つ)。処理中状態は動かさない(権威は hook)。
1849
+ actor.lastTurnStartMs = Math.max(actor.lastTurnStartMs ?? 0, event.atMs);
1850
+ return;
1851
+ }
1852
+ if (event.kind !== "interrupted" && event.kind !== "api_error")
1853
+ return;
1854
+ if (actor.processingSince === null) {
1855
+ // 処理中でなくても終端の時刻は覚える: 終端より前に発火した hook(別プロセス起動 + relay で遅着する
1856
+ // UserPromptSubmit / Pre・PostToolUse)は既に終わったターンの残響として無視する
1857
+ // (late-hook-before-turn-end)。連続 API エラー(実測 2026-09-06: エラー行の 69ms 後に queued 発話が
1858
+ // 自動 dequeue され 1.15s 後に再びエラー)で 2 本目のマーカーが新ターンの hook より先に届いても、
1859
+ // その hook の発火時刻はマーカーより古いので処理中へ戻さない。
1860
+ actor.lastTurnEndMs = Math.max(actor.lastTurnEndMs ?? 0, event.atMs);
1861
+ return;
1862
+ }
1863
+ if (actor.processingSinceMs !== null && event.atMs < actor.processingSinceMs)
1864
+ return;
1865
+ this.options.log?.(`${event.kind === "api_error" ? "api-error" : "interrupt"} marker で処理完了を補完 session=${session}`);
1866
+ actor.lastTurnEndMs = Math.max(actor.lastTurnEndMs ?? 0, event.atMs);
1867
+ // 3 秒の残響窓(発火時刻を持たない旧 hook 向けのフォールバック)は利用者の中断だけに武装する。
1868
+ // API エラー後は queued 発話の自動 dequeue や task-notification の再開が数十 ms で始まり得る
1869
+ // (実測 51〜69ms)ため、窓を張ると生きた新ターンの継続 hook を握り潰して idle へ書き戻す。
1870
+ if (event.kind === "interrupted")
1871
+ actor.lastInterruptDoneMs = this.nowMs();
1872
+ this.applyProcessing(session, "done");
1873
+ this.broadcast({ type: "session_processing", session, state: "done" });
1874
+ }
1875
+ /** 遅着 hook が直書きした active の heartbeat を idle へ戻す(その hook の書込のままのときだけ)。 */
1876
+ revertLateHookHeartbeat(session, event, reason) {
1877
+ // 別の書込(新ターンの UserPromptSubmit 等)に置き換わっていれば触らない(生きたターンの reaper
1878
+ // 保護を剥がさない)。
1879
+ try {
1880
+ const latest = readHeartbeat(this.options.heartbeatDir, session);
1881
+ if (latest !== null && latest.state === "active" && latest.event === event) {
1882
+ writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: "idle", event: reason });
1883
+ }
1884
+ }
1885
+ catch (error) {
1886
+ this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
1887
+ }
1888
+ }
1889
+ /**
1890
+ * 処理中状態を反映する。戻り値 false は「中断済みターンの残響として無視した」(呼び手は
1891
+ * broadcast しない)。`event` は hook 名(relay 由来。codex controller / 旧 hook は undefined)。
1892
+ */
1893
+ applyProcessing(session, state, event, atMs) {
1604
1894
  const actor = this.actor(session);
1605
- actor.processingSince = state === "active" ? this.now() : null;
1895
+ if (state === "active") {
1896
+ // 発火時刻付きの hook(hook プロセスの開始時刻。node 起動遅延を含まない)が transcript の最新の
1897
+ // ターン終端(中断確定 / API エラー)より前(かつ配送遅延として説明できる古さ)なら、既に終わった
1898
+ // ターンの遅着 hook。UserPromptSubmit でも同じ(連続 API エラーで自動 dequeue されたターンの hook
1899
+ // が、その終端マーカーより後に届く実測)。処理中(新ターン確定後)に届く古い hook は処理中を
1900
+ // 続けるだけなので無視しない。
1901
+ // UserPromptSubmit は、終端マーカーより後に新しい発話が transcript に現れていれば新ターン
1902
+ // (queued 発話の dequeue)として採用する。発火時刻だけでは「終わったターンの遅着」と
1903
+ // 「enqueue 時に発火して dequeue まで待った発話」を区別できないため、transcript を権威にする。
1904
+ const newerTurnObserved = actor.lastTurnStartMs !== null && actor.lastTurnEndMs !== null &&
1905
+ actor.lastTurnStartMs > actor.lastTurnEndMs;
1906
+ if (actor.processingSince === null && atMs !== undefined && actor.lastTurnEndMs !== null &&
1907
+ atMs < actor.lastTurnEndMs && actor.lastTurnEndMs - atMs <= LATE_HOOK_MAX_AGE_MS &&
1908
+ !(event === "UserPromptSubmit" && newerTurnObserved)) {
1909
+ this.options.log?.(`audit late-hook-before-turn-end ignored session=${session} event=${event} firedAtMs=${atMs} turnEndMs=${actor.lastTurnEndMs}`);
1910
+ // hook が直書きした active の heartbeat も戻す(reaper の bump 代行で不死化させない)。
1911
+ this.revertLateHookHeartbeat(session, event, "late-hook-before-turn-end");
1912
+ return false;
1913
+ }
1914
+ // 中断直後に遅着する継続 hook(Pre/PostToolUse)は中断済みターンの残響。hook は別プロセス
1915
+ // 起動+relay で 100〜300ms 遅れるため、マーカー書込より後に届き得る。採用すると Stop hook の
1916
+ // 無いターンが永久に処理中へ戻る(停止ボタン再点灯)。新ターンの開始は UserPromptSubmit
1917
+ // だけが権威(中断→即再送信は通常どおり active)。発火時刻の有無に関わらず 3 秒窓を保つ
1918
+ // (Esc で abort されたツールの PostToolUse がマーカーの数百 ms 後に発火し得るため、発火時刻で
1919
+ // 窓を狭めない。実機 09-02 の根治を退行させない)。
1920
+ if (isContinuationHookEvent(event) && actor.processingSince === null && actor.lastInterruptDoneMs !== null &&
1921
+ this.nowMs() - actor.lastInterruptDoneMs < LATE_HOOK_AFTER_INTERRUPT_MS) {
1922
+ this.options.log?.(`audit late-hook-after-interrupt ignored session=${session} event=${event}`);
1923
+ this.revertLateHookHeartbeat(session, event, "late-hook-after-interrupt");
1924
+ return false;
1925
+ }
1926
+ actor.processingSince = this.now();
1927
+ // ターン開始は UserPromptSubmit で確定し、継続 hook では進めない(未確定なら今)。
1928
+ if (event === "UserPromptSubmit")
1929
+ actor.turnStartFiredAtMs = atMs ?? null;
1930
+ actor.processingSinceMs =
1931
+ event === "UserPromptSubmit" || actor.processingSinceMs === null ? this.nowMs() : actor.processingSinceMs;
1932
+ }
1933
+ else {
1934
+ // 前ターンの Stop が新ターンの UserPromptSubmit より後に届いた(queued 発話の自動 dequeue で
1935
+ // 両 hook がほぼ同時に発火し、到着順が入れ替わる)場合、現ターンを落とさない
1936
+ // (late-stop-before-turn-start)。発火時刻は現ターンの UserPromptSubmit の発火時刻と比べる
1937
+ // (hub 到着時刻と比べると、短いターンの正規の Stop が UserPromptSubmit の到着前に発火して
1938
+ // 誤って捨てられる)。
1939
+ if (atMs !== undefined && actor.turnStartFiredAtMs !== null && atMs < actor.turnStartFiredAtMs) {
1940
+ this.options.log?.(`audit late-stop-before-turn-start ignored session=${session} event=${event} firedAtMs=${atMs} turnStartMs=${actor.turnStartFiredAtMs}`);
1941
+ // Stop hook は relay より先に heartbeat を idle で直書きする(sinceMs も落ちる)。無視した Stop の
1942
+ // 書込のままなら現ターンの active(開始時刻付き)へ戻す(hub 再起動時の復元と reaper 保護を
1943
+ // 失わないように)。別の書込に置き換わっていれば触らない。
1944
+ try {
1945
+ const latest = readHeartbeat(this.options.heartbeatDir, session);
1946
+ if (latest !== null && latest.state === "idle" && latest.event === event) {
1947
+ writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: "active",
1948
+ event: "hub-processing",
1949
+ ...(actor.processingSinceMs !== null ? { sinceMs: actor.processingSinceMs } : {}) });
1950
+ }
1951
+ }
1952
+ catch (error) {
1953
+ this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
1954
+ }
1955
+ return false;
1956
+ }
1957
+ actor.processingSince = null;
1958
+ actor.processingSinceMs = null;
1959
+ actor.turnStartFiredAtMs = null;
1960
+ }
1606
1961
  // 一覧 watch 中は処理開始/完了で pump を起動/停止する(前面購読とは独立)。
1607
1962
  this.syncPreview(session, actor);
1608
1963
  try {
1609
1964
  writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: state === "active" ? "active" : "idle",
1610
- event: state === "active" ? "hub-processing" : "hub-processing-done" });
1965
+ event: state === "active" ? "hub-processing" : "hub-processing-done",
1966
+ // 再起動後の中断マーカー照合用に開始時刻を残す(bump は保持する)。
1967
+ ...(actor.processingSinceMs !== null ? { sinceMs: actor.processingSinceMs } : {}) });
1611
1968
  }
1612
1969
  catch (error) {
1613
1970
  this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
@@ -1618,6 +1975,7 @@ export class SessionHub {
1618
1975
  this.broadcast({ type: "question_event", session, event: "dismiss", id });
1619
1976
  void this.drainChatQueue(session, actor);
1620
1977
  }
1978
+ return true;
1621
1979
  }
1622
1980
  bumpSafe(session, event, fallbackState = "idle") {
1623
1981
  try {
@@ -1928,6 +2286,13 @@ function sameCodexRetry(left, right) {
1928
2286
  left.sandbox === right.sandbox &&
1929
2287
  left.threadId === right.threadId && left.cwd === right.cwd;
1930
2288
  }
2289
+ /** 利用中モデル通知(`pc:model`)の conversation_event payload。 */
2290
+ function codexModelMarker(model) {
2291
+ return {
2292
+ type: "chat_output", v: PROTOCOL_V1, streamId: CODEX_MODEL_STREAM_ID,
2293
+ role: "system", text: model, eof: true,
2294
+ };
2295
+ }
1931
2296
  function incrementCount(counts, key) {
1932
2297
  counts.set(key, (counts.get(key) ?? 0) + 1);
1933
2298
  }