@tea-agent/loop-agent 0.33.6-beta.0 → 0.33.6

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 (64) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-write-guard.js +26 -8
  8. package/dist/shared/operator/capabilities.js +72 -42
  9. package/dist/shared/resilient-git.js +133 -0
  10. package/dist/task/source-prepare/artifact-meta.js +137 -0
  11. package/dist/task/source-prepare/index.js +2 -0
  12. package/dist/task/source-prepare/parse-intent.js +58 -10
  13. package/dist/task/source-prepare/prepare.js +180 -16
  14. package/dist/task/source-prepare/reference-integrity.js +18 -2
  15. package/dist/task/source-prepare/semantic-intake.js +404 -0
  16. package/dist/worker/console/app-data.js +2 -0
  17. package/dist/worker/console/chat/chat-event-store.js +190 -25
  18. package/dist/worker/console/chat/pi-console-config.js +250 -32
  19. package/dist/worker/console/chat/pi-runtime.js +625 -71
  20. package/dist/worker/console/chat/resource-loader.js +5 -4
  21. package/dist/worker/console/chat/routes.js +324 -146
  22. package/dist/worker/console/chat/runtime-context.js +48 -12
  23. package/dist/worker/console/chat/runtime-selection.js +59 -0
  24. package/dist/worker/console/chat/shortcuts.js +1 -0
  25. package/dist/worker/console/chat/tool-adapter.js +9 -3
  26. package/dist/worker/console/chat/tools.js +5 -1
  27. package/dist/worker/console/dag-execution-receipt.js +380 -0
  28. package/dist/worker/console/operator-actions.js +559 -68
  29. package/dist/worker/console/server.js +8 -15
  30. package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
  31. package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
  32. package/dist/worker/console/static/index.html +2 -2
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  34. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  35. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  36. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
  37. package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
  38. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  40. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  41. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  42. package/dist/worker/delivery/final-verification.js +13 -5
  43. package/dist/worker/delivery/package.js +31 -19
  44. package/dist/worker/delivery/verification-bundle.js +6 -4
  45. package/dist/worker/observe/static/operator-chrome.css +5 -2
  46. package/dist/worker/observe/static/operator-chrome.js +6 -1
  47. package/dist/worker/observe/static/styles.css +39 -9
  48. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +2 -27
  49. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  50. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  51. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  52. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  53. package/dist/workflows/dag/init-hybrid.js +12 -20
  54. package/dist/workflows/dag/types.js +0 -7
  55. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  56. package/docs/templates/backend-test-dag.json +10 -10
  57. package/harness.json +1 -1
  58. package/package.json +1 -1
  59. package/skills/loop-agent/references/command-reference.md +3 -1
  60. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  61. package/skills/loop-agent/references/task-workflow.md +4 -0
  62. package/dist/worker/console/chat/instruction-skills.js +0 -217
  63. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  64. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -1,56 +1,175 @@
1
1
  import { useCallback, useEffect } from "react";
2
2
  import { formatOperatorUserError } from "../../operator-user-error.js";
3
3
  import { confirmationToken, requestId, sleep } from "./format.js";
4
- /** Inline prompt SSE + background reconnect GET /events stream (M1-T04/T05). */
4
+ function sseEventName(block) {
5
+ return (block
6
+ .split("\n")
7
+ .find((line) => line.startsWith("event: "))
8
+ ?.slice(7)
9
+ .trim() ?? "message");
10
+ }
11
+ function sleepBriefly() {
12
+ return new Promise((resolve) => window.setTimeout(resolve, 25));
13
+ }
14
+ /** One GET /events stream owns all Chat events. Sending a prompt creates a
15
+ * detached Turn resource only; it never opens a second inline SSE consumer. */
5
16
  export function useChatStream(params) {
6
- const { origin, refs, session, thread, composer, setError, setAutoScroll } = params;
17
+ const { origin, refs, session, thread, composer, setError, setAutoScroll, recoveryVersion = 0, } = params;
7
18
  const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
8
19
  const { setInput, setPendingImages } = composer;
20
+ /** Follow one session's event stream until it is explicitly aborted. The
21
+ * server's `stream_ready` marker separates replay from live events; this
22
+ * means replay cannot render duplicate messages while live text remains
23
+ * smooth (M4.1/M4.3). */
24
+ const openEventsStream = useCallback(async (sessionId, generation, signal) => {
25
+ while (!signal.aborted) {
26
+ const state = refs.eventsStreamStateRef.current;
27
+ if (!state ||
28
+ state.sessionId !== sessionId ||
29
+ state.generation !== generation)
30
+ return;
31
+ state.ready = false;
32
+ try {
33
+ const headers = {
34
+ Accept: "text/event-stream",
35
+ };
36
+ const last = refs.lastEventIdRef.current;
37
+ if (last)
38
+ headers["Last-Event-ID"] = last;
39
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/events`, { headers, credentials: "include", signal });
40
+ if (!res.ok || !res.body) {
41
+ if (res.status === 404)
42
+ return;
43
+ await sleep(2_000, signal);
44
+ continue;
45
+ }
46
+ const reader = res.body.getReader();
47
+ const decoder = new TextDecoder();
48
+ let buffer = "";
49
+ let replay = true;
50
+ const fallbackAssistantId = requestId("a");
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done)
54
+ break;
55
+ buffer += decoder.decode(value, { stream: true });
56
+ const blocks = buffer.split("\n\n");
57
+ buffer = blocks.pop() ?? "";
58
+ for (const block of blocks) {
59
+ if (sseEventName(block) === "stream_ready") {
60
+ const current = refs.eventsStreamStateRef.current;
61
+ if (current?.sessionId === sessionId &&
62
+ current.generation === generation)
63
+ current.ready = true;
64
+ replay = false;
65
+ continue;
66
+ }
67
+ const assistantId = refs.assistantIdRef.current ?? fallbackAssistantId;
68
+ const eventId = applySseBlock(block, assistantId, {
69
+ replay,
70
+ targetSessionId: sessionId,
71
+ generation,
72
+ });
73
+ if (eventId)
74
+ refs.lastEventIdRef.current = eventId;
75
+ }
76
+ }
77
+ const current = refs.eventsStreamStateRef.current;
78
+ if (current?.sessionId === sessionId &&
79
+ current.generation === generation)
80
+ current.ready = false;
81
+ await sleep(1_000, signal);
82
+ }
83
+ catch {
84
+ if (signal.aborted)
85
+ return;
86
+ await sleep(2_000, signal);
87
+ }
88
+ }
89
+ }, [origin, refs, applySseBlock]);
90
+ const startEventsStream = useCallback((sessionId, generation) => {
91
+ const existing = refs.eventsStreamStateRef.current;
92
+ if (existing?.sessionId === sessionId &&
93
+ existing.generation === generation &&
94
+ refs.reconnectAbortRef.current)
95
+ return;
96
+ refs.reconnectAbortRef.current?.abort();
97
+ const controller = new AbortController();
98
+ refs.reconnectAbortRef.current = controller;
99
+ refs.eventsStreamStateRef.current = {
100
+ sessionId,
101
+ generation,
102
+ ready: false,
103
+ };
104
+ void openEventsStream(sessionId, generation, controller.signal);
105
+ }, [refs, openEventsStream]);
106
+ /** Wait until the GET stream completed its retained replay and entered follow
107
+ * mode. The bounded wait gives an actionable error rather than accidentally
108
+ * falling back to a second POST-SSE owner (M4.1). */
109
+ const ensureEventsConnected = useCallback(async (sessionId, generation) => {
110
+ startEventsStream(sessionId, generation);
111
+ const deadline = Date.now() + 5_000;
112
+ while (Date.now() < deadline) {
113
+ const state = refs.eventsStreamStateRef.current;
114
+ if (state?.sessionId === sessionId &&
115
+ state.generation === generation &&
116
+ state.ready)
117
+ return;
118
+ if (refs.sessionRef.current?.sessionId !== sessionId ||
119
+ refs.sessionGenerationRef.current !== generation)
120
+ throw new Error("会话已切换");
121
+ await sleepBriefly();
122
+ }
123
+ throw new Error("事件连接未就绪,请检查 Console 连接后重试");
124
+ }, [refs, startEventsStream]);
9
125
  const sendPrompt = useCallback(async (text, images) => {
10
- // Read from refs (the source of truth) so callers that captured a
11
- // stale closure — notably suggestion chips chaining
12
- // createSession().then(sendPrompt) — still see the freshly created
13
- // session rather than the null from an earlier render.
14
- const session = refs.sessionRef.current;
15
- if (!session ||
126
+ const activeSession = refs.sessionRef.current;
127
+ const generation = refs.sessionGenerationRef.current;
128
+ if (!activeSession ||
16
129
  (!text.trim() && images.length === 0) ||
17
130
  refs.streamingRef.current)
18
131
  return;
132
+ try {
133
+ await ensureEventsConnected(activeSession.sessionId, generation);
134
+ }
135
+ catch (error) {
136
+ if (refs.sessionRef.current?.sessionId === activeSession.sessionId &&
137
+ refs.sessionGenerationRef.current === generation) {
138
+ setError(formatOperatorUserError(error instanceof Error ? error.message : String(error)));
139
+ }
140
+ return;
141
+ }
142
+ if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
143
+ refs.sessionGenerationRef.current !== generation)
144
+ return;
19
145
  const userMsg = {
20
146
  id: requestId("u"),
21
147
  role: "user",
22
148
  text: text.trim(),
23
149
  createdAt: Date.now(),
24
150
  };
25
- setMessages((m) => [...m, userMsg]);
151
+ const assistantId = requestId("a");
152
+ setMessages((messages) => [
153
+ ...messages,
154
+ userMsg,
155
+ { id: assistantId, role: "assistant", text: "", createdAt: Date.now() },
156
+ ]);
26
157
  setInput("");
27
- // Stop the background replay stream before opening the inline SSE
28
- // stream. Otherwise both connections can consume the same persisted
29
- // event under different assistant ids and render duplicate bubbles.
30
- refs.reconnectAbortRef.current?.abort();
31
- refs.reconnectAbortRef.current = null;
32
- refs.streamingRef.current = true;
33
- setStreaming(true);
34
158
  setAutoScroll(true);
35
159
  setError(null);
36
- const assistantId = requestId("a");
160
+ refs.streamingRef.current = true;
37
161
  refs.assistantIdRef.current = assistantId;
162
+ setStreaming(true);
38
163
  setStreamingAssistantId(assistantId);
39
- setMessages((m) => [
40
- ...m,
41
- { id: assistantId, role: "assistant", text: "", createdAt: Date.now() },
42
- ]);
43
- const ac = new AbortController();
44
- refs.abortRef.current = ac;
45
- refs.sessionAbortControllersRef.current.set(session.sessionId, ac);
46
- refs.inlineStreamSessionIdsRef.current.add(session.sessionId);
164
+ const controller = new AbortController();
165
+ refs.abortRef.current = controller;
166
+ refs.sessionAbortControllersRef.current.set(activeSession.sessionId, controller);
47
167
  try {
48
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${session.sessionId}/prompt`, {
168
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/turns`, {
49
169
  method: "POST",
50
170
  headers: {
51
171
  "Content-Type": "application/json",
52
172
  "x-loop-console-confirmation": confirmationToken(),
53
- Accept: "text/event-stream",
54
173
  },
55
174
  credentials: "include",
56
175
  body: JSON.stringify({
@@ -61,134 +180,106 @@ export function useChatStream(params) {
61
180
  data,
62
181
  })),
63
182
  }),
64
- signal: ac.signal,
183
+ signal: controller.signal,
65
184
  });
66
- if (!res.ok || !res.body) {
67
- const body = (await res.json().catch(() => ({})));
185
+ const body = (await res.json().catch(() => ({})));
186
+ if (!res.ok || !body.turnId)
68
187
  throw new Error(body.error?.message ?? `HTTP ${res.status}`);
69
- }
70
- const reader = res.body.getReader();
71
- const decoder = new TextDecoder();
72
- let buffer = "";
73
- for (;;) {
74
- const { done, value } = await reader.read();
75
- if (done)
76
- break;
77
- buffer += decoder.decode(value, { stream: true });
78
- const events = buffer.split("\n\n");
79
- buffer = events.pop() ?? "";
80
- for (const block of events) {
81
- const eid = applySseBlock(block, assistantId, {
82
- targetSessionId: session.sessionId,
83
- });
84
- if (eid)
85
- refs.lastEventIdRef.current = eid;
86
- }
87
- }
88
- }
89
- catch (e) {
90
- if (e?.name === "AbortError") {
91
- // user stopped; keep partial assistant text
92
- }
93
- else if (refs.sessionRef.current?.sessionId === session.sessionId) {
94
- setError(formatOperatorUserError(e instanceof Error ? e.message : String(e)));
95
- }
188
+ // Slow POST returns cannot alter a later selection/turn (M5.2).
189
+ if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
190
+ refs.sessionGenerationRef.current !== generation)
191
+ return;
192
+ refs.activeTurnIdRef.current = body.turnId;
193
+ refs.turnAssistantIdsRef.current.set(body.turnId, assistantId);
194
+ setPendingImages([]);
96
195
  }
97
- finally {
98
- if (refs.sessionRef.current?.sessionId === session.sessionId) {
196
+ catch (error) {
197
+ if (error?.name !== "AbortError" &&
198
+ refs.sessionRef.current?.sessionId === activeSession.sessionId &&
199
+ refs.sessionGenerationRef.current === generation) {
200
+ setError(formatOperatorUserError(error instanceof Error ? error.message : String(error)));
99
201
  refs.streamingRef.current = false;
202
+ refs.assistantIdRef.current = null;
100
203
  setStreaming(false);
101
204
  setStreamingAssistantId(null);
102
- refs.abortRef.current = null;
103
- refs.assistantIdRef.current = null;
104
- setPendingImages([]);
205
+ setMessages((messages) => messages.filter((message) => message.id !== assistantId || message.text.trim()));
105
206
  }
106
- refs.inlineStreamSessionIdsRef.current.delete(session.sessionId);
107
- refs.sessionAbortControllersRef.current.delete(session.sessionId);
207
+ }
208
+ finally {
209
+ refs.sessionAbortControllersRef.current.delete(activeSession.sessionId);
210
+ if (refs.abortRef.current === controller)
211
+ refs.abortRef.current = null;
108
212
  }
109
213
  }, [
110
214
  origin,
111
215
  refs,
112
- applySseBlock,
216
+ ensureEventsConnected,
113
217
  setMessages,
114
- setStreaming,
115
- setStreamingAssistantId,
116
218
  setInput,
117
- setPendingImages,
118
219
  setAutoScroll,
119
220
  setError,
221
+ setStreaming,
222
+ setStreamingAssistantId,
223
+ setPendingImages,
120
224
  ]);
121
225
  const handleStop = useCallback(() => {
122
226
  refs.abortRef.current?.abort();
227
+ if (refs.activeTurnIdRef.current)
228
+ refs.settledTurnIdsRef.current.add(refs.activeTurnIdRef.current);
123
229
  refs.streamingRef.current = false;
230
+ refs.activeTurnIdRef.current = null;
124
231
  setStreaming(false);
125
232
  setStreamingAssistantId(null);
126
- // User-initiated stop: immediately drop the still-empty placeholder
127
- // bubble (no text yet) instead of leaving a forever "" row (pi-web
128
- // hides empty assistant rows on abort).
129
- setMessages((m) => m.filter((msg) => !(msg.id === refs.assistantIdRef.current &&
130
- msg.role === "assistant" &&
131
- !msg.text.trim())));
233
+ setMessages((messages) => messages.filter((message) => !(message.id === refs.assistantIdRef.current &&
234
+ message.role === "assistant" &&
235
+ !message.text.trim())));
236
+ refs.assistantIdRef.current = null;
132
237
  }, [refs, setStreaming, setStreamingAssistantId, setMessages]);
133
- // Reconcile client streaming state with the server (pi-web get_state loop).
134
- // When SSE events are missed (network drop, backgrounded tab, half-open
135
- // connection) agent_end/agent_settled never arrive and the UI stays
136
- // streaming forever. If the server reports no active turn while we still
137
- // think a turn is streaming, force the same finish a terminal event would
138
- // have produced. Also refreshes activeTurnIdRef so replayed agent_start
139
- // frames are judged against current server truth.
238
+ // State reconciliation is only a status fence: it never consumes/creates
239
+ // message events, and a generation check discards late responses (M5.3).
140
240
  useEffect(() => {
141
241
  if (!session || !streaming)
142
242
  return;
143
- const sid = session.sessionId;
243
+ const sessionId = session.sessionId;
244
+ const generation = refs.sessionGenerationRef.current;
144
245
  let cancelled = false;
145
246
  const sync = async () => {
146
- if (cancelled)
147
- return;
148
247
  try {
149
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sid)}/state`, { credentials: "include" });
150
- if (!res.ok)
248
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/state`, { credentials: "include" });
249
+ const body = (await res.json().catch(() => ({})));
250
+ if (cancelled ||
251
+ !res.ok ||
252
+ refs.sessionRef.current?.sessionId !== sessionId ||
253
+ refs.sessionGenerationRef.current !== generation)
151
254
  return;
152
- const body = (await res.json());
153
255
  const activeTurnId = body.state?.activeTurnId ?? null;
154
256
  refs.activeTurnIdRef.current = activeTurnId;
155
257
  if (activeTurnId !== null || !refs.streamingRef.current)
156
258
  return;
157
- // Server idle while the client still thinks a turn is streaming.
158
- // Skip when an inline prompt request is still in flight — the
159
- // server may not have created its turn lease yet (POST prompt
160
- // creates the turn server-side after the request lands), and
161
- // force-finishing then would kill a just-sent prompt.
162
- if (refs.inlineStreamSessionIdsRef.current.has(sid))
163
- return;
164
- const pendingAssistantId = refs.assistantIdRef.current;
259
+ const assistantId = refs.assistantIdRef.current;
165
260
  refs.streamingRef.current = false;
261
+ refs.assistantIdRef.current = null;
166
262
  setStreaming(false);
167
263
  setStreamingAssistantId(null);
168
- refs.abortRef.current?.abort();
169
- refs.abortRef.current = null;
170
- refs.assistantIdRef.current = null;
171
- setMessages((m) => m.filter((msg) => !(msg.id === pendingAssistantId &&
172
- msg.role === "assistant" &&
173
- !msg.text.trim())));
264
+ setMessages((messages) => messages.filter((message) => !(message.id === assistantId &&
265
+ message.role === "assistant" &&
266
+ !message.text.trim())));
174
267
  }
175
268
  catch {
176
- // Network still down — the next poll / visibility / online tick retries.
269
+ // offline/background case: interval, visibility and online retry.
177
270
  }
178
271
  };
179
272
  const onVisible = () => {
180
273
  if (document.visibilityState === "visible")
181
274
  void sync();
182
275
  };
183
- const onOnline = () => {
184
- void sync();
185
- };
186
- const interval = setInterval(() => void sync(), 15_000);
276
+ const onOnline = () => void sync();
277
+ const interval = window.setInterval(() => void sync(), 15_000);
187
278
  document.addEventListener("visibilitychange", onVisible);
188
279
  window.addEventListener("online", onOnline);
189
280
  return () => {
190
281
  cancelled = true;
191
- clearInterval(interval);
282
+ window.clearInterval(interval);
192
283
  document.removeEventListener("visibilitychange", onVisible);
193
284
  window.removeEventListener("online", onOnline);
194
285
  };
@@ -201,87 +292,22 @@ export function useChatStream(params) {
201
292
  setStreamingAssistantId,
202
293
  setMessages,
203
294
  ]);
204
- /** Open the reconnectable GET /events stream for a session and apply events
205
- * until it closes, then reopen with the latest Last-Event-ID (M1-T04/T05).
206
- *
207
- * The stream is strictly a recovery path: it is stopped before an inline
208
- * prompt SSE starts, then reopened after that prompt settles. This preserves
209
- * one owner for an in-flight assistant bubble while still replaying the
210
- * durable tail after refreshes and network blips. */
211
- const openEventsStream = useCallback(async (sessionId, signal) => {
212
- while (!signal.aborted) {
213
- try {
214
- const headers = {
215
- Accept: "text/event-stream",
216
- };
217
- const last = refs.lastEventIdRef.current;
218
- if (last)
219
- headers["Last-Event-ID"] = last;
220
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${sessionId}/events`, { headers, credentials: "include", signal });
221
- if (!res.ok || !res.body) {
222
- // 404 = session gone; stop the loop quietly.
223
- if (res.status === 404)
224
- return;
225
- // transient error: back off before retrying.
226
- await sleep(2000, signal);
227
- continue;
228
- }
229
- const reader = res.body.getReader();
230
- const decoder = new TextDecoder();
231
- let buffer = "";
232
- // Reconnect assistant id: events arriving here belong to whichever
233
- // turn is/was active; reuse the in-flight one or allocate a stable
234
- // placeholder so streaming text patches a single bubble.
235
- let rcAssistantId = refs.assistantIdRef.current;
236
- if (!rcAssistantId) {
237
- rcAssistantId = requestId("a");
238
- refs.assistantIdRef.current = rcAssistantId;
239
- }
240
- for (;;) {
241
- const { done, value } = await reader.read();
242
- if (done)
243
- break;
244
- buffer += decoder.decode(value, { stream: true });
245
- const blocks = buffer.split("\n\n");
246
- buffer = blocks.pop() ?? "";
247
- for (const block of blocks) {
248
- const eid = applySseBlock(block, rcAssistantId, {
249
- replay: true,
250
- targetSessionId: sessionId,
251
- });
252
- if (eid)
253
- refs.lastEventIdRef.current = eid;
254
- }
255
- }
256
- // Server closed the follow window (idle). Reopen from the last id
257
- // after a short pause unless we were aborted.
258
- await sleep(1000, signal);
259
- }
260
- catch (e) {
261
- if (signal.aborted)
262
- return;
263
- // Network blip: back off and retry (keeps reconnect resilient).
264
- await sleep(2000, signal);
265
- }
266
- }
267
- }, [origin, refs, applySseBlock]);
268
- // Reconnect effect: open/refresh the events stream whenever the active
269
- // session or streaming state changes. Aborts the previous stream on cleanup
270
- // so switching sessions, starting an inline prompt, or unmounting does not
271
- // leak a dangling connection. It resumes after settle/stop to catch the tail.
295
+ // Exactly one owner starts/aborts GET /events. Switching sessions or
296
+ // recovering a cursor aborts the prior stream before the next one is made.
272
297
  useEffect(() => {
273
- if (!session ||
274
- (streaming &&
275
- refs.inlineStreamSessionIdsRef.current.has(session.sessionId)))
298
+ if (!session)
276
299
  return;
277
- const ac = new AbortController();
278
- refs.reconnectAbortRef.current = ac;
279
- void openEventsStream(session.sessionId, ac.signal);
300
+ const generation = refs.sessionGenerationRef.current;
301
+ startEventsStream(session.sessionId, generation);
280
302
  return () => {
281
- ac.abort();
282
- if (refs.reconnectAbortRef.current === ac)
303
+ const state = refs.eventsStreamStateRef.current;
304
+ if (state?.sessionId === session.sessionId &&
305
+ state.generation === generation) {
306
+ refs.reconnectAbortRef.current?.abort();
283
307
  refs.reconnectAbortRef.current = null;
308
+ refs.eventsStreamStateRef.current = null;
309
+ }
284
310
  };
285
- }, [session, streaming, openEventsStream, refs]);
311
+ }, [session, recoveryVersion, refs, startEventsStream]);
286
312
  return { sendPrompt, handleStop, openEventsStream };
287
313
  }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { buildTurnProcessGroups } from "../../chat/turn-process.js";
3
- import { createChatSseEventApplier } from "./chat-sse-events.js";
3
+ import { createChatSseEventApplier, } from "./chat-sse-events.js";
4
4
  import { taskContextUnchanged } from "./format.js";
5
5
  const EMPTY_USAGE = {
6
6
  inputTokens: 0,
@@ -15,7 +15,7 @@ const EMPTY_USAGE = {
15
15
  * slices it patches and the derived timeline projections.
16
16
  */
17
17
  export function useChatThread(params) {
18
- const { origin, refs, sessionId, setError, setProcessOpen } = params;
18
+ const { origin, refs, sessionId, setError, setProcessOpen, onReconcile } = params;
19
19
  const [messages, setMessages] = useState([]);
20
20
  const [toolCalls, setToolCalls] = useState([]);
21
21
  const [operationCards, setOperationCards] = useState([]);
@@ -35,6 +35,7 @@ export function useChatThread(params) {
35
35
  // applier identity is stable across renders.
36
36
  const applyChatSseEvent = useMemo(() => createChatSseEventApplier({
37
37
  refs,
38
+ onReconcile,
38
39
  setError,
39
40
  setProcessOpen,
40
41
  setInterview,
@@ -47,12 +48,14 @@ export function useChatThread(params) {
47
48
  setUsage,
48
49
  setMessages,
49
50
  setToolCalls,
50
- }), [refs, setError, setProcessOpen]);
51
+ }), [refs, onReconcile, setError, setProcessOpen]);
51
52
  /** Parse + apply one raw SSE block (frames separated by \n\n). Returns the
52
53
  * eventId if the block carried an `id:` line (for Last-Event-ID tracking). */
53
54
  const applySseBlock = useCallback((block, assistantId, options) => {
54
- if (options?.targetSessionId &&
55
- refs.sessionRef.current?.sessionId !== options.targetSessionId)
55
+ if ((options?.targetSessionId &&
56
+ refs.sessionRef.current?.sessionId !== options.targetSessionId) ||
57
+ (options?.generation !== undefined &&
58
+ refs.sessionGenerationRef.current !== options.generation))
56
59
  return null;
57
60
  const lines = block.split("\n");
58
61
  let eventName = "message";
@@ -186,6 +189,46 @@ export function useChatThread(params) {
186
189
  setUnread(0);
187
190
  setHistoryLimit(50);
188
191
  }, [refs]);
192
+ /** Reconcile recovery (AC-001/AC-002/AC-003): reload the canonical session
193
+ * facts (session-store messages + event-ring tail cursor) and REPLACE the
194
+ * thread projection. Never calls applyActivatedSession: that would clear the
195
+ * operation/human-gate/artifact/interview cards, which the reconnected tail
196
+ * stream will not replay (they live in the ring, already consumed). */
197
+ const recoverSession = useCallback(async (targetSessionId) => {
198
+ try {
199
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(targetSessionId)}`, { credentials: "include" });
200
+ if (!res.ok)
201
+ return false;
202
+ const body = (await res.json());
203
+ if (!body.session ||
204
+ refs.sessionRef.current?.sessionId !== targetSessionId)
205
+ return false;
206
+ const activeTurnId = body.session.activeTurnId ?? null;
207
+ refs.activeTurnIdRef.current = activeTurnId;
208
+ refs.streamingRef.current = Boolean(activeTurnId);
209
+ // Resume the events stream from the snapshot tail so the next
210
+ // reconnect never immediately re-expires (AC-003). The refs below
211
+ // are reset so any follow-up tail replay re-applies cleanly.
212
+ refs.lastEventIdRef.current = body.lastEventId ?? null;
213
+ refs.seenEventIdsRef.current = new Set();
214
+ refs.turnAssistantIdsRef.current = new Map();
215
+ setMessages((body.session.messages ?? [])
216
+ .filter((message) => message.role === "user" || message.role === "assistant")
217
+ .map((message) => ({
218
+ id: message.id,
219
+ role: message.role,
220
+ text: message.text,
221
+ createdAt: Date.parse(message.at) || Date.now(),
222
+ })));
223
+ setStreamingAssistantId(null);
224
+ return true;
225
+ }
226
+ catch {
227
+ // Reload failed: keep the old cursor; the next stream reopen will
228
+ // trigger another reconcile and retry (best-effort recovery).
229
+ return false;
230
+ }
231
+ }, [origin, refs, setMessages, setStreamingAssistantId]);
189
232
  return {
190
233
  messages,
191
234
  setMessages,
@@ -214,5 +257,6 @@ export function useChatThread(params) {
214
257
  loadTaskContext,
215
258
  resetForNewSession,
216
259
  applyActivatedSession,
260
+ recoverSession,
217
261
  };
218
262
  }
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
2
2
  import { SHORTCUTS } from "../../chat/shortcuts.js";
3
3
  import { confirmationToken } from "./format.js";
4
+ import { attachRuntimeSnapshotController } from "./useRuntimeSnapshot.js";
4
5
  /** Composer state: input text, @file palette, image attachments, /shortcuts,
5
6
  * IME composition guards, auto-grow and server-side draft sync. */
6
7
  export function useComposer(params) {
@@ -103,6 +104,20 @@ export function useComposer(params) {
103
104
  refs.isComposingRef.current = false;
104
105
  refs.compositionEndedAtRef.current = Date.now();
105
106
  }, [refs]);
107
+ /** IME guard used by the ChatComposer keyboard wrapper (mirrors OperatorChat). */
108
+ const isComposingNow = useCallback(() => {
109
+ return refs.isComposingRef.current;
110
+ }, [refs]);
111
+ /** 100ms post-composition window (mirrors OperatorChat). */
112
+ const recentlyComposedNow = useCallback(() => {
113
+ return Date.now() - refs.compositionEndedAtRef.current < 100;
114
+ }, [refs]);
115
+ // Bind the shared runtime snapshot to the active session (the composer owns
116
+ // origin + sessionId + refs in a writable file; the snapshot store is
117
+ // consumed by ChatComposer / panels / runtime-context popover).
118
+ useEffect(() => {
119
+ attachRuntimeSnapshotController({ origin, sessionId, refs });
120
+ }, [origin, sessionId, refs]);
106
121
  return {
107
122
  input,
108
123
  setInput,
@@ -121,5 +136,7 @@ export function useComposer(params) {
121
136
  textareaRef,
122
137
  onCompositionStart,
123
138
  onCompositionEnd,
139
+ isComposingNow,
140
+ recentlyComposedNow,
124
141
  };
125
142
  }