tailii-host 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approval/hook.js +11 -1
- package/dist/approval/hook.js.map +1 -1
- package/dist/backend/herdr.d.ts +30 -1
- package/dist/backend/herdr.js +103 -20
- package/dist/backend/herdr.js.map +1 -1
- package/dist/backend/sessionBackend.d.ts +12 -0
- package/dist/backend/sessionBackend.js +6 -0
- package/dist/backend/sessionBackend.js.map +1 -1
- package/dist/backend/tmux.d.ts +153 -3
- package/dist/backend/tmux.js +488 -14
- package/dist/backend/tmux.js.map +1 -1
- package/dist/chat/chatTailController.d.ts +3 -1
- package/dist/chat/chatTailController.js +3 -0
- package/dist/chat/chatTailController.js.map +1 -1
- package/dist/chat/subagentTailer.d.ts +51 -0
- package/dist/chat/subagentTailer.js +491 -47
- package/dist/chat/subagentTailer.js.map +1 -1
- package/dist/chat/subagentTranscript.js +51 -5
- package/dist/chat/subagentTranscript.js.map +1 -1
- package/dist/chat/transcriptTailer.d.ts +37 -1
- package/dist/chat/transcriptTailer.js +170 -3
- package/dist/chat/transcriptTailer.js.map +1 -1
- package/dist/codex/codexAppServer.js +20 -0
- package/dist/codex/codexAppServer.js.map +1 -1
- package/dist/codex/codexNativeTurnController.js +20 -4
- package/dist/codex/codexNativeTurnController.js.map +1 -1
- package/dist/codex/codexRolloutTailer.d.ts +1 -0
- package/dist/codex/codexRolloutTailer.js +7 -0
- package/dist/codex/codexRolloutTailer.js.map +1 -1
- package/dist/codex/codexSystemNotice.d.ts +16 -0
- package/dist/codex/codexSystemNotice.js +195 -0
- package/dist/codex/codexSystemNotice.js.map +1 -0
- package/dist/commands/doctor.d.ts +2 -6
- package/dist/commands/doctor.js +2 -29
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/launch.d.ts +2 -0
- package/dist/commands/launch.js +5 -0
- package/dist/commands/launch.js.map +1 -1
- package/dist/engine/engine.js +32 -22
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/handlers/accountUsage.d.ts +17 -0
- package/dist/engine/handlers/accountUsage.js +40 -6
- package/dist/engine/handlers/accountUsage.js.map +1 -1
- package/dist/engine/handlers/mode.js +42 -1
- package/dist/engine/handlers/mode.js.map +1 -1
- package/dist/engine/handlers/session.js +5 -3
- package/dist/engine/handlers/session.js.map +1 -1
- package/dist/engine/handlers/usage.js +4 -1
- package/dist/engine/handlers/usage.js.map +1 -1
- package/dist/engine/handlers/workspace.js +38 -1
- package/dist/engine/handlers/workspace.js.map +1 -1
- package/dist/hub/effortDialog.d.ts +20 -0
- package/dist/hub/effortDialog.js +40 -0
- package/dist/hub/effortDialog.js.map +1 -0
- package/dist/hub/engineRelaySocket.d.ts +3 -0
- package/dist/hub/engineRelaySocket.js +2 -1
- package/dist/hub/engineRelaySocket.js.map +1 -1
- package/dist/hub/hubDaemon.js +20 -3
- package/dist/hub/hubDaemon.js.map +1 -1
- package/dist/hub/hubProtocol.js +8 -2
- package/dist/hub/hubProtocol.js.map +1 -1
- package/dist/hub/panePreviewPump.d.ts +27 -0
- package/dist/hub/panePreviewPump.js +62 -1
- package/dist/hub/panePreviewPump.js.map +1 -1
- package/dist/hub/questionInjection.js +2 -1
- package/dist/hub/questionInjection.js.map +1 -1
- package/dist/hub/reaper.d.ts +5 -0
- package/dist/hub/reaper.js +25 -7
- package/dist/hub/reaper.js.map +1 -1
- package/dist/hub/sessionHub.d.ts +48 -1
- package/dist/hub/sessionHub.js +248 -25
- package/dist/hub/sessionHub.js.map +1 -1
- package/dist/protocol/decode.js +45 -0
- package/dist/protocol/decode.js.map +1 -1
- package/dist/protocol/encode.js +2 -0
- package/dist/protocol/encode.js.map +1 -1
- package/dist/protocol/messages.d.ts +65 -1
- package/dist/services/accountIdentity.js +3 -1
- package/dist/services/accountIdentity.js.map +1 -1
- package/dist/services/fileService.d.ts +19 -1
- package/dist/services/fileService.js +126 -0
- package/dist/services/fileService.js.map +1 -1
- package/dist/services/keychainMirror.d.ts +9 -0
- package/dist/services/keychainMirror.js +64 -0
- package/dist/services/keychainMirror.js.map +1 -0
- package/dist/services/officialApps.d.ts +12 -0
- package/dist/services/officialApps.js +84 -30
- package/dist/services/officialApps.js.map +1 -1
- package/dist/services/planUsageFetcher.d.ts +53 -5
- package/dist/services/planUsageFetcher.js +109 -22
- package/dist/services/planUsageFetcher.js.map +1 -1
- package/dist/sessions/claudeSessionStore.js +30 -0
- package/dist/sessions/claudeSessionStore.js.map +1 -1
- package/dist/sessions/heartbeat.d.ts +7 -1
- package/dist/sessions/heartbeat.js +3 -1
- package/dist/sessions/heartbeat.js.map +1 -1
- package/dist/sessions/sessionSearch.js +23 -5
- package/dist/sessions/sessionSearch.js.map +1 -1
- package/dist/shared/cliVersions.d.ts +14 -0
- package/dist/shared/cliVersions.js +38 -0
- package/dist/shared/cliVersions.js.map +1 -0
- package/dist/shared/crossSession.d.ts +15 -0
- package/dist/shared/crossSession.js +75 -0
- package/dist/shared/crossSession.js.map +1 -0
- package/dist/shared/harnessReminder.d.ts +32 -0
- package/dist/shared/harnessReminder.js +124 -0
- package/dist/shared/harnessReminder.js.map +1 -0
- package/dist/shared/lineWriter.d.ts +7 -0
- package/dist/shared/lineWriter.js +26 -0
- package/dist/shared/lineWriter.js.map +1 -1
- package/dist/shared/systemNotice.d.ts +30 -0
- package/dist/shared/systemNotice.js +209 -0
- package/dist/shared/systemNotice.js.map +1 -0
- package/package.json +3 -3
package/dist/hub/sessionHub.js
CHANGED
|
@@ -2,17 +2,40 @@
|
|
|
2
2
|
// tailii (TS host) — connection 非依存 Session Hub コア。
|
|
3
3
|
import { reaperTick } from "./reaper.js";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
5
6
|
import * as path from "node:path";
|
|
6
7
|
import { bumpHeartbeat, listHeartbeatSessions, readHeartbeat, writeHeartbeat } from "../sessions/heartbeat.js";
|
|
7
|
-
import { ensureDirectory0700 } from "../shared/paths.js";
|
|
8
|
+
import { claudeProjectSlug, ensureDirectory0700 } from "../shared/paths.js";
|
|
8
9
|
import { decodeHubClientLine, encodeHubMessage, } from "./hubProtocol.js";
|
|
9
|
-
import { HISTORY_DONE_STREAM_ID } from "../chat/transcriptTailer.js";
|
|
10
|
+
import { HISTORY_DONE_STREAM_ID, findTrailingInterruptMarkerMs, } from "../chat/transcriptTailer.js";
|
|
10
11
|
import { PROTOCOL_V1, PROTOCOL_V2 } from "../protocol.js";
|
|
12
|
+
import { LoginCodeError } from "../backend/tmux.js";
|
|
11
13
|
import { CodexAppServerManager } from "../codex/codexAppServer.js";
|
|
12
14
|
import { CodexNativeTurnController, chatContentKey, } from "../codex/codexNativeTurnController.js";
|
|
13
15
|
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";
|
|
16
|
+
/**
|
|
17
|
+
* backfilling 中の購読者は publishConversationEvent が丸ごと飛ばす。旗が立ちっぱなしになると
|
|
18
|
+
* 「接続は健全なのに会話だけ永久に更新されない」実障害になるため、履歴が一定時間 1 行も進まな
|
|
19
|
+
* ければ強制的に live へ切り替える。進捗があるうちは延長するので、巨大な transcript の再生を
|
|
20
|
+
* 途中で打ち切ることはない。
|
|
21
|
+
*/
|
|
22
|
+
const BACKFILL_STALL_TIMEOUT_MS = 15_000;
|
|
14
23
|
const DELIVERED_RECEIPT_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
15
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
|
+
/** ターンを始めない継続 hook か(新ターンの権威は UserPromptSubmit のみ)。 */
|
|
28
|
+
function isContinuationHookEvent(event) {
|
|
29
|
+
return event === "PreToolUse" || event === "PostToolUse";
|
|
30
|
+
}
|
|
31
|
+
/** 既定の claude transcript 解決(`~/.claude/projects/<slug>/<sessionId>.jsonl`)。ID 未記録は null。 */
|
|
32
|
+
function defaultTranscriptPathFor(meta) {
|
|
33
|
+
// 他の tail open と同じ優先順(providerSessionId → claudeSessionId)。別会話を照合しない。
|
|
34
|
+
const sessionId = meta.providerSessionId ?? meta.claudeSessionId;
|
|
35
|
+
if (sessionId === undefined || sessionId.length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
return path.join(os.homedir(), ".claude", "projects", claudeProjectSlug(meta.cwd), `${sessionId}.jsonl`);
|
|
38
|
+
}
|
|
16
39
|
export class SessionHub {
|
|
17
40
|
options;
|
|
18
41
|
clients = new Map();
|
|
@@ -20,6 +43,7 @@ export class SessionHub {
|
|
|
20
43
|
previewWatchers = new Set();
|
|
21
44
|
actors = new Map();
|
|
22
45
|
now;
|
|
46
|
+
nowMs;
|
|
23
47
|
replayLimit;
|
|
24
48
|
injectionsInFlight = 0;
|
|
25
49
|
codexStartsInFlight = 0;
|
|
@@ -30,6 +54,7 @@ export class SessionHub {
|
|
|
30
54
|
constructor(options) {
|
|
31
55
|
this.options = options;
|
|
32
56
|
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
57
|
+
this.nowMs = options.nowMs ?? (() => Date.now());
|
|
33
58
|
this.replayLimit = options.replayLimit ?? 500;
|
|
34
59
|
}
|
|
35
60
|
get clientCount() { return this.clients.size; }
|
|
@@ -61,9 +86,51 @@ export class SessionHub {
|
|
|
61
86
|
// (稼働中の hub は毎 tick bump しているので、直前まで生きていた heartbeat は必ず新しい)。
|
|
62
87
|
if (this.now() - heartbeat.ts >= this.options.timeoutSeconds)
|
|
63
88
|
continue;
|
|
64
|
-
|
|
89
|
+
// 再起動前に利用者が中断して放置した会話は Stop hook が無く active のまま残る。engine の
|
|
90
|
+
// 再接続購読は newerThanMs 付きで履歴のマーカーを流さないため、tail 経由では二度と
|
|
91
|
+
// 観測できない。transcript 末尾を直接照合し、最後の発話が開始以降の中断マーカーなら
|
|
92
|
+
// 処理中に戻さず idle へ確定する。
|
|
93
|
+
const trailingInterruptMs = this.trailingInterruptMarkerMs(session);
|
|
94
|
+
if (trailingInterruptMs !== null && (heartbeat.sinceMs === undefined || trailingInterruptMs >= heartbeat.sinceMs)) {
|
|
95
|
+
// 再起動中に hook が新ターンの active を書いた直後なら上書きしない(読んだ内容と同じときだけ倒す)。
|
|
96
|
+
const latest = readHeartbeat(this.options.heartbeatDir, session);
|
|
97
|
+
const unchanged = latest !== null && latest.ts === heartbeat.ts && latest.state === heartbeat.state &&
|
|
98
|
+
latest.event === heartbeat.event && latest.sinceMs === heartbeat.sinceMs;
|
|
99
|
+
if (!unchanged) {
|
|
100
|
+
// 消えていた(kill 後の掃除)場合も再生成しない。
|
|
101
|
+
this.options.log?.(`restore: heartbeat が更新されたため中断判定を見送る session=${session}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
try {
|
|
105
|
+
writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: "idle", event: "restore-interrupted" });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
|
|
109
|
+
}
|
|
110
|
+
this.options.log?.(`restore: 中断済みのため処理中を復元しない session=${session}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const actor = this.actor(session);
|
|
115
|
+
actor.processingSince = heartbeat.ts;
|
|
116
|
+
// 真の開始時刻は heartbeat の sinceMs(applyProcessing が書き、bump が保持する)。ts は
|
|
117
|
+
// 毎 tick bump されるので使えない。無ければ(hook 直書きの heartbeat・旧形式)復元時刻を
|
|
118
|
+
// 境界にする: 再起動前の中断は上の transcript 照合が担い、再起動後の中断は必ずこれより
|
|
119
|
+
// 新しい。null(全採用)にすると、開き直しの全履歴再生で流れる過去ターンのマーカーが
|
|
120
|
+
// 進行中ターンを落とす。
|
|
121
|
+
actor.processingSinceMs = heartbeat.sinceMs ?? this.nowMs();
|
|
65
122
|
}
|
|
66
123
|
}
|
|
124
|
+
/** 復元時の照合用: transcript 末尾の中断マーカー timestamp(claude 会話のみ。不明は null)。 */
|
|
125
|
+
trailingInterruptMarkerMs(session) {
|
|
126
|
+
const meta = this.options.metadataStore.get(session);
|
|
127
|
+
if (meta === null || meta.agent === "codex")
|
|
128
|
+
return null;
|
|
129
|
+
const transcriptPath = (this.options.transcriptPathFor ?? defaultTranscriptPathFor)(meta);
|
|
130
|
+
if (transcriptPath === null)
|
|
131
|
+
return null;
|
|
132
|
+
return findTrailingInterruptMarkerMs(transcriptPath);
|
|
133
|
+
}
|
|
67
134
|
/** 永続化済み設問を復元する。App Server の request handle は再起動を越せないため TUI のみ対象。 */
|
|
68
135
|
restorePendingQuestions() {
|
|
69
136
|
const restored = this.readPendingQuestions();
|
|
@@ -200,8 +267,10 @@ export class SessionHub {
|
|
|
200
267
|
if (message.event === "dismiss")
|
|
201
268
|
void this.drainChatQueue(message.session, actor);
|
|
202
269
|
}
|
|
203
|
-
else if (message.type === "session_processing")
|
|
204
|
-
this.applyProcessing(message.session, message.state)
|
|
270
|
+
else if (message.type === "session_processing") {
|
|
271
|
+
if (!this.applyProcessing(message.session, message.state, message.event))
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
205
274
|
this.broadcast(message);
|
|
206
275
|
}
|
|
207
276
|
handleClientMessage(client, line) {
|
|
@@ -546,7 +615,8 @@ export class SessionHub {
|
|
|
546
615
|
actor.runtimeClaim = null;
|
|
547
616
|
return;
|
|
548
617
|
}
|
|
549
|
-
this.applyProcessing(message.session, message.state)
|
|
618
|
+
if (!this.applyProcessing(message.session, message.state, message.event))
|
|
619
|
+
return;
|
|
550
620
|
this.broadcast(message);
|
|
551
621
|
}
|
|
552
622
|
async tick() {
|
|
@@ -692,6 +762,10 @@ export class SessionHub {
|
|
|
692
762
|
else {
|
|
693
763
|
this.syncPreview(session, actor);
|
|
694
764
|
}
|
|
765
|
+
// 会話を開き直したとき(pump は一覧 watch で稼働中)、静止した入力待ちダイアログの
|
|
766
|
+
// フレームは変化しないので再送しないと転写カードが出ない。
|
|
767
|
+
if (preview && !wasPreview)
|
|
768
|
+
actor.previewPump?.resendLastIfInteractive?.();
|
|
695
769
|
// preview=false 中に engine が route できなかった image/subagent event を、同じ
|
|
696
770
|
// subscriber の前面昇格時にも afterSeq から回収する。既存購読だからと no-op にしない。
|
|
697
771
|
if (!existing.backfilling && afterSeq !== undefined) {
|
|
@@ -716,6 +790,8 @@ export class SessionHub {
|
|
|
716
790
|
}
|
|
717
791
|
// 初回 backfill が同期的に多数の行を生成する前に pane capture を開始する。
|
|
718
792
|
this.syncPreview(session, actor);
|
|
793
|
+
if (preview)
|
|
794
|
+
actor.previewPump?.resendLastIfInteractive?.();
|
|
719
795
|
if (first) {
|
|
720
796
|
this.startSharedTail(session, actor, newerThanMs ?? null);
|
|
721
797
|
// processing 完了で一度 unsubscribe された後も、actor の replay buffer が残る間は
|
|
@@ -791,7 +867,7 @@ export class SessionHub {
|
|
|
791
867
|
return;
|
|
792
868
|
const tail = this.options.tailFactory((payload) => {
|
|
793
869
|
this.publishConversationEvent(session, actor, payload);
|
|
794
|
-
});
|
|
870
|
+
}, undefined, (event) => this.handleClaudeTurnLifecycle(session, actor, event));
|
|
795
871
|
actor.tail = tail;
|
|
796
872
|
tail.open(meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, newerThanMs, meta.agent ?? "claude");
|
|
797
873
|
}
|
|
@@ -1024,20 +1100,48 @@ export class SessionHub {
|
|
|
1024
1100
|
const meta = this.options.metadataStore.get(session);
|
|
1025
1101
|
if (meta === null)
|
|
1026
1102
|
return;
|
|
1027
|
-
|
|
1103
|
+
// 離脱→再購読で同じ client オブジェクトが**別の** SubscriberState / tail を持つ。古い
|
|
1104
|
+
// backfill の後始末が新しい購読を壊さないよう、自分が始めた分だけを識別して触る。
|
|
1105
|
+
const ownState = actor.subscribers.get(client);
|
|
1106
|
+
ownState.backfilling = true;
|
|
1028
1107
|
const boundarySeq = actor.nextServerSeq - 1;
|
|
1029
1108
|
const liveCountsAtStart = new Map(actor.codexLive?.publishedContentCounts ?? []);
|
|
1030
1109
|
const backfillCounts = new Map();
|
|
1031
1110
|
let completedSynchronously = false;
|
|
1111
|
+
let finished = false;
|
|
1112
|
+
let stallTimer = null;
|
|
1032
1113
|
let tail = null;
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1114
|
+
const clearStallTimer = () => {
|
|
1115
|
+
if (stallTimer === null)
|
|
1116
|
+
return;
|
|
1117
|
+
clearTimeout(stallTimer);
|
|
1118
|
+
stallTimer = null;
|
|
1119
|
+
};
|
|
1120
|
+
const finish = (reason) => {
|
|
1121
|
+
if (finished)
|
|
1122
|
+
return;
|
|
1123
|
+
finished = true;
|
|
1124
|
+
clearStallTimer();
|
|
1125
|
+
// 自分の tail だけを管理表から外す。停滞タイマーが再購読後に発火した場合、無条件に
|
|
1126
|
+
// delete すると**新しい** tail が管理外になり、以後 unsubscribe しても停止できない。
|
|
1127
|
+
if (actor.backfillTails.get(client) === tail)
|
|
1128
|
+
actor.backfillTails.delete(client);
|
|
1129
|
+
const superseded = actor.subscribers.get(client) !== ownState;
|
|
1130
|
+
completedSynchronously = tail === null;
|
|
1131
|
+
tail?.stop();
|
|
1132
|
+
// 既に離脱済み、または再購読で別の backfill が走っているなら状態は触らない。
|
|
1133
|
+
if (superseded)
|
|
1036
1134
|
return;
|
|
1037
1135
|
// 共有 tail の emit 時点でイベントは transcript に存在する。その後 EOF まで読む
|
|
1038
1136
|
// 履歴 tail に必ず含まれるため、同じ同期処理で live へ切り替えても取りこぼさない。
|
|
1039
|
-
|
|
1137
|
+
ownState.backfilling = false;
|
|
1138
|
+
if (reason !== "history-done") {
|
|
1139
|
+
this.options.log?.(`audit backfill-force-finish session=${session} reason=${reason}`);
|
|
1140
|
+
}
|
|
1040
1141
|
if (actor.codexLive !== null) {
|
|
1142
|
+
// codex は rollout(履歴) と app-server(live) が同じ発話を二重に表すため、履歴側が
|
|
1143
|
+
// 何を出したかで打ち消してから境界後を replay する。強制完了で履歴が途中まで進んで
|
|
1144
|
+
// いた場合も `backfillCounts` にその分が入っているので、同じ経路が正しく効く。
|
|
1041
1145
|
const representedAfterBoundary = new Map();
|
|
1042
1146
|
for (const [key, count] of backfillCounts) {
|
|
1043
1147
|
const excess = count - (liveCountsAtStart.get(key) ?? 0);
|
|
@@ -1056,23 +1160,60 @@ export class SessionHub {
|
|
|
1056
1160
|
this.sendTo(client, { type: "conversation_event", session, ...event });
|
|
1057
1161
|
}
|
|
1058
1162
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1163
|
+
else if (reason !== "history-done") {
|
|
1164
|
+
// 履歴を最後まで読めていない。境界後のイベントは履歴側に含まれないため replay で補う
|
|
1165
|
+
// (止まった backfill が境界後の行まで到達している見込みは薄く、重複表示のリスクより
|
|
1166
|
+
// 応答が丸ごと欠ける実害のほうが大きい)。
|
|
1167
|
+
for (const event of actor.replayBuffer) {
|
|
1168
|
+
if (event.serverSeq <= boundarySeq)
|
|
1169
|
+
continue;
|
|
1170
|
+
this.sendTo(client, { type: "conversation_event", session, ...event });
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
// 進捗は「最後に 1 行流れた時刻」で測る。履歴行ごとに setTimeout を張り替えると
|
|
1175
|
+
// 巨大な transcript でタイマー生成が支配的になるため、タイマーは 1 本のまま残時間を見る。
|
|
1176
|
+
let lastProgressAt = Date.now();
|
|
1177
|
+
const armStallTimer = (delayMs) => {
|
|
1178
|
+
stallTimer = setTimeout(() => {
|
|
1179
|
+
stallTimer = null;
|
|
1180
|
+
if (finished)
|
|
1181
|
+
return;
|
|
1182
|
+
const remaining = BACKFILL_STALL_TIMEOUT_MS - (Date.now() - lastProgressAt);
|
|
1183
|
+
if (remaining > 0) {
|
|
1184
|
+
armStallTimer(remaining);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
finish("stalled");
|
|
1188
|
+
}, delayMs);
|
|
1189
|
+
stallTimer.unref();
|
|
1062
1190
|
};
|
|
1063
1191
|
tail = this.options.tailFactory((payload) => {
|
|
1064
1192
|
this.sendTo(client, { type: "conversation_event", session, serverSeq: 0, payload });
|
|
1193
|
+
lastProgressAt = Date.now();
|
|
1065
1194
|
const key = chatContentKey(payload);
|
|
1066
1195
|
if (key !== null)
|
|
1067
1196
|
incrementCount(backfillCounts, key);
|
|
1068
1197
|
if (payload.type === "chat_output" && payload.streamId === HISTORY_DONE_STREAM_ID)
|
|
1069
|
-
finish();
|
|
1198
|
+
finish("history-done");
|
|
1070
1199
|
});
|
|
1071
1200
|
if (!completedSynchronously)
|
|
1072
1201
|
actor.backfillTails.set(client, tail);
|
|
1073
|
-
|
|
1074
|
-
|
|
1202
|
+
try {
|
|
1203
|
+
tail.open(meta.cwd, meta.providerSessionId ?? meta.claudeSessionId ?? null, newerThanMs, meta.agent ?? "claude");
|
|
1204
|
+
}
|
|
1205
|
+
catch (error) {
|
|
1206
|
+
// transcript を開けない会話で旗を立てたまま抜けると live 配信が永久に止まる。
|
|
1207
|
+
this.options.log?.(`audit backfill-open-failed session=${session} error=${String(error)}`);
|
|
1208
|
+
finish("open-failed");
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (completedSynchronously) {
|
|
1075
1212
|
tail.stop();
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (!finished)
|
|
1216
|
+
armStallTimer(BACKFILL_STALL_TIMEOUT_MS);
|
|
1076
1217
|
}
|
|
1077
1218
|
canReplay(actor, afterSeq) {
|
|
1078
1219
|
const first = actor.replayBuffer[0]?.serverSeq ?? actor.nextServerSeq;
|
|
@@ -1148,7 +1289,8 @@ export class SessionHub {
|
|
|
1148
1289
|
actor(session) {
|
|
1149
1290
|
let actor = this.actors.get(session);
|
|
1150
1291
|
if (actor === undefined) {
|
|
1151
|
-
actor = { pendingQuestion: null, processingSince: null,
|
|
1292
|
+
actor = { pendingQuestion: null, processingSince: null, processingSinceMs: null, lastInterruptDoneMs: null,
|
|
1293
|
+
focusedBy: new Set(), subscribers: new Map(),
|
|
1152
1294
|
nextServerSeq: 1, replayBuffer: [], tail: null, tailRetryTimer: null,
|
|
1153
1295
|
previewPump: null, backfillTails: new Map(),
|
|
1154
1296
|
seenClientMessageIds: new Set(), deliveredChatMessageIds: new Map(),
|
|
@@ -1356,14 +1498,25 @@ export class SessionHub {
|
|
|
1356
1498
|
continue;
|
|
1357
1499
|
actor.injectingChatMessageIds.delete(message.clientMessageId);
|
|
1358
1500
|
actor.pendingChatMessages.delete(message.clientMessageId);
|
|
1359
|
-
|
|
1501
|
+
// `/login` 中の門番(LoginCodeError)は 1 キーも送る前の確定拒否 = 非配達が確定している。
|
|
1502
|
+
// uncertain(配送不明)に積むと削除不能・後続ブロックのゾンビになるので、失敗として
|
|
1503
|
+
// そのまま片付ける。それ以外の注入失敗は従来どおり配送不明として明示再送に倒す。
|
|
1504
|
+
const rejectedBeforeSend = error instanceof LoginCodeError;
|
|
1505
|
+
if (rejectedBeforeSend) {
|
|
1506
|
+
removeOrderedID(actor.chatOrder, message.clientMessageId);
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
actor.uncertainChatMessages.set(message.clientMessageId, message);
|
|
1510
|
+
}
|
|
1360
1511
|
this.persistChatReceipts();
|
|
1512
|
+
// 利用者向け文言だけを出す(`String(error)` は `LoginCodeError: …` と型名が前置される)。
|
|
1513
|
+
const userText = rejectedBeforeSend && error instanceof Error ? error.message : String(error);
|
|
1361
1514
|
for (const waiter of entry.waiters) {
|
|
1362
1515
|
this.sendTo(waiter.client, {
|
|
1363
|
-
type: "chat_send_result", id: waiter.id, status: "failed", error:
|
|
1516
|
+
type: "chat_send_result", id: waiter.id, status: "failed", error: userText,
|
|
1364
1517
|
});
|
|
1365
1518
|
}
|
|
1366
|
-
this.publishCodexMarker(session, `chat-send-error-${message.id}`, `⚠️ メッセージ送信失敗: ${
|
|
1519
|
+
this.publishCodexMarker(session, `chat-send-error-${message.id}`, `⚠️ メッセージ送信失敗: ${userText}`);
|
|
1367
1520
|
this.options.log?.(`chat 注入失敗 session=${session}: ${String(error)}`);
|
|
1368
1521
|
}
|
|
1369
1522
|
finally {
|
|
@@ -1528,14 +1681,83 @@ export class SessionHub {
|
|
|
1528
1681
|
type: "chat_output", v: PROTOCOL_V1, streamId, role: "system", text, eof: true,
|
|
1529
1682
|
});
|
|
1530
1683
|
}
|
|
1531
|
-
|
|
1684
|
+
/**
|
|
1685
|
+
* transcript の中断確定マーカーで処理完了を補完する。Claude Code の Stop hook は利用者の中断
|
|
1686
|
+
* (Esc / Ctrl-C)では発火しないため、hook だけでは処理中状態がターン終了後も残り、iOS は
|
|
1687
|
+
* 会話を開き直す・再接続するたびに停止ボタン/処理中表示を張り直してしまう(実機 09-02)。
|
|
1688
|
+
* 共有 tail は購読の張り直しで履歴から再生されるため、マーカーの timestamp が現ターンの
|
|
1689
|
+
* 処理開始時刻以降のものだけを採用する(過去ターンのマーカーで進行中ターンを落とさない)。
|
|
1690
|
+
*
|
|
1691
|
+
* 時刻の比較根拠: `processingSinceMs` は UserPromptSubmit hook を hub が受信した時刻(ターン
|
|
1692
|
+
* 開始。継続 hook では進めない)。中断→即再送信(実測 10〜200ms 後)でも、新ターンの hook
|
|
1693
|
+
* 受信は必ずマーカー書込より後(同一マシンの時計・因果順)なので、マーカーは新ターンの開始
|
|
1694
|
+
* より古く見えて棄却される。tail がマーカーを hook より先に届けた場合は前ターンを done に
|
|
1695
|
+
* し、直後の UserPromptSubmit で active に戻る。中断直後に遅着する Pre/PostToolUse の active
|
|
1696
|
+
* は `applyProcessing` が残響として無視する。
|
|
1697
|
+
*
|
|
1698
|
+
* 副作用は hook の Stop と同じ `applyProcessing("done")` 一式(heartbeat idle。設問提示中なら
|
|
1699
|
+
* その dismiss と chat queue の drain)。中断はターン終了なので Stop 後と同じ振る舞い。
|
|
1700
|
+
*/
|
|
1701
|
+
handleClaudeTurnLifecycle(session, actor, event) {
|
|
1702
|
+
if (event.kind !== "interrupted")
|
|
1703
|
+
return;
|
|
1704
|
+
if (this.actors.get(session) !== actor)
|
|
1705
|
+
return;
|
|
1706
|
+
if (actor.processingSince === null)
|
|
1707
|
+
return;
|
|
1708
|
+
if (this.options.metadataStore.get(session)?.agent === "codex")
|
|
1709
|
+
return;
|
|
1710
|
+
if (actor.processingSinceMs !== null && event.atMs < actor.processingSinceMs)
|
|
1711
|
+
return;
|
|
1712
|
+
this.options.log?.(`interrupt marker で処理完了を補完 session=${session}`);
|
|
1713
|
+
actor.lastInterruptDoneMs = this.nowMs();
|
|
1714
|
+
this.applyProcessing(session, "done");
|
|
1715
|
+
this.broadcast({ type: "session_processing", session, state: "done" });
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* 処理中状態を反映する。戻り値 false は「中断済みターンの残響として無視した」(呼び手は
|
|
1719
|
+
* broadcast しない)。`event` は hook 名(relay 由来。codex controller / 旧 hook は undefined)。
|
|
1720
|
+
*/
|
|
1721
|
+
applyProcessing(session, state, event) {
|
|
1532
1722
|
const actor = this.actor(session);
|
|
1533
|
-
|
|
1723
|
+
if (state === "active") {
|
|
1724
|
+
// 中断直後に遅着する継続 hook(Pre/PostToolUse)は中断済みターンの残響。hook は別プロセス
|
|
1725
|
+
// 起動+relay で 100〜300ms 遅れるため、マーカー書込より後に届き得る。採用すると Stop hook の
|
|
1726
|
+
// 無いターンが永久に処理中へ戻る(停止ボタン再点灯)。新ターンの開始は UserPromptSubmit
|
|
1727
|
+
// だけが権威(中断→即再送信は通常どおり active)。
|
|
1728
|
+
if (isContinuationHookEvent(event) && actor.processingSince === null && actor.lastInterruptDoneMs !== null &&
|
|
1729
|
+
this.nowMs() - actor.lastInterruptDoneMs < LATE_HOOK_AFTER_INTERRUPT_MS) {
|
|
1730
|
+
this.options.log?.(`audit late-hook-after-interrupt ignored session=${session} event=${event}`);
|
|
1731
|
+
// hook が直書きした active の heartbeat も戻す(reaper の bump 代行で不死化させない)。
|
|
1732
|
+
// ただし heartbeat がその hook の書込のままのときだけ。別の書込(新ターンの
|
|
1733
|
+
// UserPromptSubmit 等)に置き換わっていれば触らない(生きたターンの reaper 保護を剥がさない)。
|
|
1734
|
+
try {
|
|
1735
|
+
const latest = readHeartbeat(this.options.heartbeatDir, session);
|
|
1736
|
+
if (latest !== null && latest.state === "active" && latest.event === event) {
|
|
1737
|
+
writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: "idle", event: "late-hook-after-interrupt" });
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
catch (error) {
|
|
1741
|
+
this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
|
|
1742
|
+
}
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
actor.processingSince = this.now();
|
|
1746
|
+
// ターン開始は UserPromptSubmit で確定し、継続 hook では進めない(未確定なら今)。
|
|
1747
|
+
actor.processingSinceMs =
|
|
1748
|
+
event === "UserPromptSubmit" || actor.processingSinceMs === null ? this.nowMs() : actor.processingSinceMs;
|
|
1749
|
+
}
|
|
1750
|
+
else {
|
|
1751
|
+
actor.processingSince = null;
|
|
1752
|
+
actor.processingSinceMs = null;
|
|
1753
|
+
}
|
|
1534
1754
|
// 一覧 watch 中は処理開始/完了で pump を起動/停止する(前面購読とは独立)。
|
|
1535
1755
|
this.syncPreview(session, actor);
|
|
1536
1756
|
try {
|
|
1537
1757
|
writeHeartbeat(this.options.heartbeatDir, session, { ts: this.now(), state: state === "active" ? "active" : "idle",
|
|
1538
|
-
event: state === "active" ? "hub-processing" : "hub-processing-done"
|
|
1758
|
+
event: state === "active" ? "hub-processing" : "hub-processing-done",
|
|
1759
|
+
// 再起動後の中断マーカー照合用に開始時刻を残す(bump は保持する)。
|
|
1760
|
+
...(actor.processingSinceMs !== null ? { sinceMs: actor.processingSinceMs } : {}) });
|
|
1539
1761
|
}
|
|
1540
1762
|
catch (error) {
|
|
1541
1763
|
this.options.log?.(`heartbeat 書込失敗: ${String(error)}`);
|
|
@@ -1546,6 +1768,7 @@ export class SessionHub {
|
|
|
1546
1768
|
this.broadcast({ type: "question_event", session, event: "dismiss", id });
|
|
1547
1769
|
void this.drainChatQueue(session, actor);
|
|
1548
1770
|
}
|
|
1771
|
+
return true;
|
|
1549
1772
|
}
|
|
1550
1773
|
bumpSafe(session, event, fallbackState = "idle") {
|
|
1551
1774
|
try {
|