@tt-a1i/openpi 0.5.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.
Files changed (74) hide show
  1. package/README.md +18 -10
  2. package/SETUP.md +8 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/bin/openpi.js +25 -15
  5. package/extensions/ai-providers/LICENSE.upstream +23 -0
  6. package/extensions/ai-providers/README.md +59 -0
  7. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  8. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  9. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  10. package/extensions/ai-providers/antigravity/models.ts +84 -0
  11. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  12. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  13. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  14. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  15. package/extensions/ai-providers/cursor/constants.ts +5 -0
  16. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  17. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  18. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  19. package/extensions/ai-providers/cursor/models.ts +45 -0
  20. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  21. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  22. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  23. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  24. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  25. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  26. package/extensions/ai-providers/index.ts +86 -0
  27. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  28. package/extensions/ai-providers/usage.ts +10 -0
  29. package/extensions/background-terminals/index.ts +8 -1
  30. package/extensions/background-terminals/src/manager.ts +3 -5
  31. package/extensions/background-terminals/src/result-delivery.ts +43 -23
  32. package/extensions/cron/index.ts +68 -27
  33. package/extensions/cron/schedule.ts +5 -1
  34. package/extensions/model-info/cache-diagnostics.ts +220 -0
  35. package/extensions/model-info/index.ts +45 -1
  36. package/extensions/plan-mode/index.ts +75 -4
  37. package/extensions/setup/index.ts +15 -3
  38. package/extensions/shared/child-session.ts +25 -5
  39. package/extensions/shared/completion-inbox.ts +193 -0
  40. package/extensions/shared/setup-config.ts +10 -1
  41. package/extensions/shared/structured-output.ts +154 -0
  42. package/extensions/subagents/index.ts +44 -4
  43. package/extensions/subagents/src/backends/pi.ts +76 -5
  44. package/extensions/subagents/src/domain.ts +16 -1
  45. package/extensions/subagents/src/manager.ts +5 -0
  46. package/extensions/subagents/src/prompt.ts +17 -3
  47. package/extensions/subagents/src/result-artifact.ts +32 -0
  48. package/extensions/subagents/src/result-delivery.ts +33 -14
  49. package/extensions/ui-customization/footer.ts +16 -5
  50. package/extensions/user-input-fold/index.ts +42 -6
  51. package/extensions/web/index.ts +25 -2
  52. package/extensions/workflows/acceptance.ts +43 -19
  53. package/extensions/workflows/completion-projection.ts +3 -1
  54. package/extensions/workflows/dashboard.ts +8 -0
  55. package/extensions/workflows/index.ts +13 -0
  56. package/extensions/workflows/model.ts +5 -1
  57. package/extensions/workflows/prompt.ts +4 -10
  58. package/extensions/workflows/result-delivery.ts +96 -22
  59. package/extensions/workflows/retention.ts +6 -0
  60. package/extensions/workflows/runner.ts +6 -71
  61. package/package.json +7 -7
  62. package/skills/subagents/REFERENCE.md +3 -2
  63. package/skills/subagents/SKILL.md +1 -0
  64. package/skills/workflows/REFERENCE.md +3 -3
  65. package/skills/workflows/SKILL.md +1 -1
  66. package/web/adapter/pi-adapter.ts +3 -0
  67. package/web/host/pi-coding-agent-entry.ts +162 -0
  68. package/web/host/web-host.ts +330 -50
  69. package/web/protocol/types.ts +5 -0
  70. package/web/runtime/pi-runtime.ts +240 -25
  71. package/web/runtime/types.ts +32 -1
  72. package/web/ui/app.js +343 -41
  73. package/web/ui/index.html +3 -0
  74. package/web/ui/styles.css +119 -37
package/web/ui/app.js CHANGED
@@ -19,9 +19,13 @@ const state = {
19
19
  collapsed: readCollapsedWorkspaces(),
20
20
  liveMessages: [],
21
21
  liveRunning: false,
22
+ activeTurn: null,
23
+ turnCancellationPending: false,
24
+ turnTerminalStatus: null,
22
25
  promptAdmissionPending: false,
23
26
  promptAdmissionToken: null,
24
27
  promptAdmissionSequence: 0,
28
+ promptAdmission: null,
25
29
  terminalPromptIds: new Set(),
26
30
  sessionEpoch: 0,
27
31
  sessionSwitching: false,
@@ -30,6 +34,9 @@ const state = {
30
34
  snapshotGeneration: 0,
31
35
  livePhase: "idle",
32
36
  liveRetry: null,
37
+ composerFeedback: null,
38
+ pendingFollowUpsReceipt: null,
39
+ themePreference: "system",
33
40
  query: "",
34
41
  selectedWorkspace: null,
35
42
  language: navigator.language?.toLowerCase().startsWith("zh") ? "zh" : "en",
@@ -67,6 +74,14 @@ const translations = {
67
74
  enterHint: "Enter to send, Shift+Enter for a new line.",
68
75
  activeOnlyHint: "Only the active Web session accepts messages.",
69
76
  acceptedHint: "Message accepted by OpenPI Web.",
77
+ admissionPendingHint: "OpenPI is still accepting the previous message.",
78
+ admissionTimeout: "Prompt admission timed out. Retrying will check the same message.",
79
+ requestTimeout: "The Web request timed out.",
80
+ reconnectingHint: "Live updates were interrupted. Reconnecting and checking canonical state...",
81
+ pendingFollowUpsHint: "Message received; {count} follow-up messages were waiting when it was received.",
82
+ stopTurn: "Stop turn",
83
+ stoppingTurn: "Stopping current turn...",
84
+ stoppedTurn: "Current turn stopped.",
70
85
  modelRunning: "Working...",
71
86
  modelPreparing: "Preparing task...",
72
87
  modelRetrying: "Retrying model request...",
@@ -103,6 +118,14 @@ const translations = {
103
118
  enterHint: "按 Enter 发送,Shift+Enter 换行。",
104
119
  activeOnlyHint: "只有当前 Web 会话可以接收消息。",
105
120
  acceptedHint: "OpenPI Web 已接收消息。",
121
+ admissionPendingHint: "OpenPI 仍在接收上一条消息,请稍候。",
122
+ admissionTimeout: "消息接收超时;重试会核对同一条消息。",
123
+ requestTimeout: "Web 请求已超时。",
124
+ reconnectingHint: "实时更新已中断,正在重连并核对权威状态……",
125
+ pendingFollowUpsHint: "消息已接收;接收时有 {count} 条后续消息等待处理。",
126
+ stopTurn: "停止当前回合",
127
+ stoppingTurn: "正在停止当前回合...",
128
+ stoppedTurn: "当前回合已停止。",
106
129
  modelRunning: "正在运行...",
107
130
  modelPreparing: "正在准备任务...",
108
131
  modelRetrying: "模型请求重试中...",
@@ -126,7 +149,24 @@ function applyLanguage() {
126
149
  }
127
150
 
128
151
  const $ = (id) => document.getElementById(id);
152
+ const supportedThemes = new Set(["system", "light", "dark"]);
153
+ const systemTheme = window.matchMedia?.("(prefers-color-scheme: dark)");
154
+ function applyThemePreference(preference) {
155
+ state.themePreference = supportedThemes.has(preference) ? preference : "system";
156
+ const resolved = state.themePreference === "system"
157
+ ? systemTheme?.matches ? "dark" : "light"
158
+ : state.themePreference;
159
+ document.documentElement.dataset.theme = resolved;
160
+ document.documentElement.style.colorScheme = resolved;
161
+ }
162
+ systemTheme?.addEventListener?.("change", () => {
163
+ if (state.themePreference === "system") applyThemePreference("system");
164
+ });
129
165
  const tokenStorageKey = "openpi.web.token";
166
+ const DEFAULT_API_TIMEOUT_MS = 15_000;
167
+ const PROMPT_ADMISSION_TIMEOUT_MS = 30_000;
168
+ const SSE_STALE_TIMEOUT_MS = 45_000;
169
+ const HEARTBEATS_PER_SNAPSHOT = 4;
130
170
  const fragmentToken = new URLSearchParams(location.hash.slice(1)).get("token");
131
171
  let token = fragmentToken;
132
172
  if (fragmentToken) {
@@ -139,6 +179,21 @@ const headers = (json = false) => ({
139
179
  Authorization: `Bearer ${token}`,
140
180
  ...(json ? { "Content-Type": "application/json" } : {}),
141
181
  });
182
+
183
+ function setComposerFeedback(message, kind = "status") {
184
+ state.composerFeedback = message ? { message, kind } : null;
185
+ const hint = $("composer-hint");
186
+ if (!hint) return;
187
+ hint.textContent = message || "";
188
+ for (const candidate of ["status", "error", "connection"]) {
189
+ hint.classList.toggle(candidate, candidate === kind && Boolean(message));
190
+ }
191
+ }
192
+
193
+ function clearComposerFeedback(kind) {
194
+ if (kind && state.composerFeedback?.kind !== kind) return;
195
+ setComposerFeedback("");
196
+ }
142
197
  const escapeHtml = (value) =>
143
198
  String(value ?? "").replace(
144
199
  /[&<>"']/g,
@@ -196,13 +251,45 @@ function renderMarkdown(value) {
196
251
  }
197
252
 
198
253
  async function api(path, options = {}) {
199
- const response = await fetch(path, {
200
- ...options,
201
- headers: { ...headers(Boolean(options.body)), ...options.headers },
202
- });
203
- const body = await response.json().catch(() => ({}));
204
- if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`);
205
- return body;
254
+ const {
255
+ timeoutMs = DEFAULT_API_TIMEOUT_MS,
256
+ timeoutMessage = t("requestTimeout"),
257
+ ...requestOptions
258
+ } = options;
259
+ const controller = new AbortController();
260
+ let timedOut = false;
261
+ const timer = window.setTimeout(() => {
262
+ timedOut = true;
263
+ controller.abort();
264
+ }, timeoutMs);
265
+ try {
266
+ const response = await fetch(path, {
267
+ ...requestOptions,
268
+ signal: controller.signal,
269
+ headers: {
270
+ ...headers(Boolean(requestOptions.body)),
271
+ ...requestOptions.headers,
272
+ },
273
+ });
274
+ const body = await response.json().catch(() => ({}));
275
+ if (!response.ok) {
276
+ const failure = new Error(body.error || `Request failed (${response.status})`);
277
+ failure.name = "WebApiResponseError";
278
+ if (typeof body.code === "string") failure.code = body.code;
279
+ failure.status = response.status;
280
+ throw failure;
281
+ }
282
+ return body;
283
+ } catch (error) {
284
+ if (timedOut) {
285
+ const timeout = new Error(timeoutMessage);
286
+ timeout.name = "WebRequestTimeout";
287
+ throw timeout;
288
+ }
289
+ throw error;
290
+ } finally {
291
+ window.clearTimeout(timer);
292
+ }
206
293
  }
207
294
 
208
295
  function sessionTitle(session) {
@@ -214,6 +301,18 @@ function compactSummary(value, limit = 96) {
214
301
  return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
215
302
  }
216
303
 
304
+ function applyPromptAcceptedState(alreadySettled) {
305
+ if (alreadySettled) {
306
+ if (state.livePhase !== "running") {
307
+ state.liveRunning = false;
308
+ state.livePhase = "idle";
309
+ }
310
+ return;
311
+ }
312
+ state.liveRunning = true;
313
+ if (state.livePhase !== "running") state.livePhase = "preparing";
314
+ }
315
+
217
316
  function relativeTime(value) {
218
317
  const elapsed = Date.now() - new Date(value).getTime();
219
318
  if (elapsed < 60_000) return "now";
@@ -482,6 +581,13 @@ function updateComposer() {
482
581
  !selected &&
483
582
  !state.snapshot?.currentSessionId;
484
583
  const canCompose = active || newSessionDraft;
584
+ const activeTurn = active
585
+ ? state.activeTurn || state.snapshot?.runtime.activeTurn || null
586
+ : null;
587
+ const canStop = Boolean(
588
+ activeTurn &&
589
+ (state.snapshot?.runtime.status === "running" || state.liveRunning),
590
+ );
485
591
  $("prompt-input").disabled =
486
592
  state.sessionSwitching || (!canCompose && Boolean(state.selectedWorkspace));
487
593
  $("send-prompt").disabled =
@@ -489,6 +595,9 @@ function updateComposer() {
489
595
  !canCompose ||
490
596
  !state.selectedWorkspace ||
491
597
  state.promptAdmissionPending;
598
+ $("send-prompt").hidden = canStop;
599
+ $("stop-turn").hidden = !canStop;
600
+ $("stop-turn").disabled = state.turnCancellationPending;
492
601
  const modelPicker = $("model-picker");
493
602
  const modelPickerValue = $("model-picker-value");
494
603
  const modelMenu = $("model-menu");
@@ -527,11 +636,30 @@ function updateComposer() {
527
636
  : active
528
637
  ? t("promptMessage")
529
638
  : t("promptReadonly");
530
- $("composer-hint").textContent = canCompose
639
+ const defaultHint = canCompose
531
640
  ? state.snapshot.runtime.status === "running" || state.liveRunning
532
641
  ? t("queuedHint")
533
642
  : t("enterHint")
534
643
  : t("activeOnlyHint");
644
+ const feedback = state.composerFeedback;
645
+ const hint = $("composer-hint");
646
+ const receipt = state.pendingFollowUpsReceipt;
647
+ hint.textContent = state.turnCancellationPending
648
+ ? t("stoppingTurn")
649
+ : state.turnTerminalStatus === "cancelled"
650
+ ? t("stoppedTurn")
651
+ : receipt !== null
652
+ ? receipt > 0
653
+ ? t("pendingFollowUpsHint").replace("{count}", String(receipt))
654
+ : t("acceptedHint")
655
+ : feedback?.message || defaultHint;
656
+ hint.classList.toggle("receipt", receipt > 0);
657
+ for (const candidate of ["status", "error", "connection"]) {
658
+ hint.classList.toggle(
659
+ candidate,
660
+ receipt === null && feedback?.kind === candidate,
661
+ );
662
+ }
535
663
  }
536
664
 
537
665
  async function selectModel(value) {
@@ -605,15 +733,19 @@ async function refreshSnapshot({
605
733
  return false;
606
734
  }
607
735
  state.snapshot = snapshot;
736
+ if (resetCursor) resetLiveState();
737
+ state.activeTurn = snapshot.runtime.activeTurn || null;
738
+ if (snapshot.runtime.status === "running") state.liveRunning = true;
739
+ applyThemePreference(snapshot.preferences?.theme);
608
740
  if (
609
741
  state.snapshot.runtime.status !== "running" &&
610
- !state.promptAdmissionPending
742
+ !state.promptAdmissionPending &&
743
+ !state.promptAdmission
611
744
  ) {
612
745
  state.liveRunning = false;
613
746
  state.livePhase = "idle";
614
747
  state.liveRetry = null;
615
748
  }
616
- if (resetCursor) resetLiveState();
617
749
  state.cursor = resetCursor || state.cursor === null
618
750
  ? state.snapshot.cursor
619
751
  : Math.max(state.cursor, state.snapshot.cursor);
@@ -640,8 +772,7 @@ async function refreshSnapshot({
640
772
  ) return false;
641
773
  $("connection-state").textContent = "Unavailable";
642
774
  $("connection-state").classList.add("reconnecting");
643
- $("composer-hint").textContent = error.message;
644
- $("composer-hint").classList.add("error");
775
+ setComposerFeedback(error.message, "error");
645
776
  return false;
646
777
  }
647
778
  }
@@ -654,6 +785,7 @@ async function selectSession(path) {
654
785
  state.selectedPath = path;
655
786
  state.promptAdmissionPending = false;
656
787
  state.promptAdmissionToken = null;
788
+ state.promptAdmission = null;
657
789
  resetLiveState();
658
790
  document.body.classList.remove("sidebar-open");
659
791
  renderWorkspaces();
@@ -718,11 +850,14 @@ async function sendPrompt() {
718
850
  await chooseWorkspace();
719
851
  }
720
852
  const content = $("prompt-input").value.trim();
853
+ if (state.promptAdmissionPending) {
854
+ setComposerFeedback(t("admissionPendingHint"));
855
+ return;
856
+ }
721
857
  if (
722
858
  !content ||
723
859
  !state.selectedWorkspace ||
724
- state.sessionSwitching ||
725
- state.promptAdmissionPending
860
+ state.sessionSwitching
726
861
  ) return;
727
862
  if (!state.snapshot?.selectedSession?.id) {
728
863
  await createSession(state.selectedWorkspace);
@@ -731,38 +866,79 @@ async function sendPrompt() {
731
866
  if (!sessionId || state.sessionSwitching || state.promptAdmissionPending) return;
732
867
  const epoch = state.sessionEpoch;
733
868
  const admissionToken = ++state.promptAdmissionSequence;
734
- const optimisticKey = `optimistic-${Date.now()}`;
735
- state.liveMessages = [
736
- ...state.liveMessages,
737
- { key: optimisticKey, message: { role: "user", content } },
738
- ].slice(-8);
869
+ const retrying =
870
+ state.promptAdmission?.sessionId === sessionId &&
871
+ state.promptAdmission?.content === content;
872
+ const commandId = retrying
873
+ ? state.promptAdmission.commandId
874
+ : globalThis.crypto?.randomUUID?.() ||
875
+ `web-prompt-${Date.now()}-${admissionToken}`;
876
+ const optimisticKey = retrying
877
+ ? state.promptAdmission.optimisticKey
878
+ : `optimistic-${commandId}`;
879
+ if (!retrying) {
880
+ state.liveMessages = [
881
+ ...state.liveMessages,
882
+ { key: optimisticKey, message: { role: "user", content } },
883
+ ].slice(-8);
884
+ }
885
+ // Keep this attempt before dispatch. A timeout may be a lost receipt, not a
886
+ // failed admission, so the next submit must replay this exact request.
887
+ state.promptAdmission = { sessionId, content, commandId, optimisticKey };
739
888
  state.promptAdmissionPending = true;
740
889
  state.promptAdmissionToken = admissionToken;
890
+ clearComposerFeedback();
891
+ state.pendingFollowUpsReceipt = null;
892
+ state.turnTerminalStatus = null;
741
893
  renderConversation();
742
- $("composer-hint").classList.remove("error");
743
894
  try {
744
895
  const receipt = await api("/api/prompt", {
745
896
  method: "POST",
746
- body: JSON.stringify({ sessionId, content }),
897
+ body: JSON.stringify({ sessionId, content, commandId, retry: retrying }),
898
+ timeoutMs: PROMPT_ADMISSION_TIMEOUT_MS,
899
+ timeoutMessage: t("admissionTimeout"),
747
900
  });
748
901
  if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return;
749
902
  const alreadySettled = state.terminalPromptIds.has(receipt.id);
750
- state.liveRunning = !alreadySettled;
751
- state.livePhase = alreadySettled ? "idle" : "preparing";
903
+ applyPromptAcceptedState(alreadySettled);
904
+ if (state.promptAdmission?.commandId === commandId) {
905
+ state.promptAdmission = null;
906
+ }
907
+ state.pendingFollowUpsReceipt = receipt.pendingFollowUps;
752
908
  $("prompt-input").value = "";
753
909
  resizePrompt();
754
- $("composer-hint").textContent = t("acceptedHint");
910
+ clearComposerFeedback();
755
911
  scheduleSnapshotRefresh(120);
756
912
  } catch (error) {
757
913
  if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return;
758
- state.liveRunning = false;
759
- state.livePhase = "idle";
760
- state.liveRetry = null;
914
+ const knownRejection =
915
+ error?.name === "WebApiResponseError" &&
916
+ [
917
+ "WORKSPACE_REQUIRED",
918
+ "SESSION_CONFLICT",
919
+ "PROMPT_REJECTED",
920
+ "COMMAND_CONFLICT",
921
+ "PROMPT_ADMISSION_CAPACITY",
922
+ ].includes(error?.code);
923
+ if (!knownRejection) {
924
+ if (state.livePhase !== "running") {
925
+ state.liveRunning = true;
926
+ state.livePhase = "preparing";
927
+ }
928
+ state.liveRetry = null;
929
+ setComposerFeedback(
930
+ error?.name === "WebRequestTimeout" ? t("admissionTimeout") : error.message,
931
+ "connection",
932
+ );
933
+ return;
934
+ }
935
+ if (state.promptAdmission?.commandId === commandId) {
936
+ state.promptAdmission = null;
937
+ }
761
938
  state.liveMessages = state.liveMessages.filter(
762
939
  (entry) => entry.key !== optimisticKey,
763
940
  );
764
- $("composer-hint").textContent = error.message;
765
- $("composer-hint").classList.add("error");
941
+ setComposerFeedback(error.message, "error");
766
942
  } finally {
767
943
  if (epoch === state.sessionEpoch && state.promptAdmissionToken === admissionToken) {
768
944
  state.promptAdmissionPending = false;
@@ -772,6 +948,36 @@ async function sendPrompt() {
772
948
  }
773
949
  }
774
950
 
951
+ async function cancelActiveTurn() {
952
+ const turn = state.activeTurn || state.snapshot?.runtime.activeTurn;
953
+ if (!turn || state.turnCancellationPending || state.sessionSwitching) return;
954
+ const epoch = state.sessionEpoch;
955
+ state.turnCancellationPending = true;
956
+ $("composer-hint").classList.remove("error");
957
+ $("composer-hint").textContent = t("stoppingTurn");
958
+ updateComposer();
959
+ try {
960
+ const receipt = await api("/api/turns/cancel", {
961
+ method: "POST",
962
+ body: JSON.stringify(turn),
963
+ });
964
+ if (epoch !== state.sessionEpoch) return;
965
+ if (receipt.state === "accepted" || receipt.state === "already-settled") {
966
+ $("composer-hint").textContent = t("stoppedTurn");
967
+ }
968
+ } catch (error) {
969
+ if (epoch !== state.sessionEpoch) return;
970
+ const message = error.message;
971
+ await refreshSnapshot({ epoch });
972
+ if (epoch === state.sessionEpoch) showNotice(message);
973
+ } finally {
974
+ if (epoch === state.sessionEpoch) {
975
+ state.turnCancellationPending = false;
976
+ renderConversation();
977
+ }
978
+ }
979
+ }
980
+
775
981
  function resizePrompt() {
776
982
  const input = $("prompt-input");
777
983
  const maxHeight = 220;
@@ -853,6 +1059,7 @@ async function createSession(workspacePath) {
853
1059
  state.selectedPath = null;
854
1060
  state.promptAdmissionPending = false;
855
1061
  state.promptAdmissionToken = null;
1062
+ state.promptAdmission = null;
856
1063
  resetLiveState();
857
1064
  document.body.classList.remove("sidebar-open");
858
1065
  renderWorkspaces();
@@ -897,8 +1104,7 @@ async function createSession(workspacePath) {
897
1104
  }
898
1105
 
899
1106
  function showNotice(message) {
900
- $("composer-hint").textContent = message;
901
- $("composer-hint").classList.add("error");
1107
+ setComposerFeedback(message, "error");
902
1108
  }
903
1109
 
904
1110
  function openWorkspaceMenu(path, anchor) {
@@ -1066,21 +1272,59 @@ function applyRuntimeEvent(event) {
1066
1272
  scheduleSnapshotRefresh();
1067
1273
  } else if (event.type === "prompt_accepted") {
1068
1274
  const alreadySettled = state.terminalPromptIds.has(event.detail?.commandId);
1069
- state.liveRunning = !alreadySettled;
1070
- state.livePhase = alreadySettled ? "idle" : "preparing";
1275
+ applyPromptAcceptedState(alreadySettled);
1276
+ state.pendingFollowUpsReceipt = Number.isInteger(event.detail?.pendingFollowUps)
1277
+ ? event.detail.pendingFollowUps
1278
+ : state.pendingFollowUpsReceipt;
1071
1279
  state.liveRetry = null;
1072
1280
  renderConversation();
1073
- } else if (event.type === "agent_start") {
1281
+ } else if (event.type === "turn_started") {
1282
+ state.activeTurn = {
1283
+ sessionId: event.detail?.sessionId,
1284
+ commandId: event.detail?.commandId,
1285
+ epoch: event.detail?.epoch,
1286
+ };
1074
1287
  state.liveRunning = true;
1288
+ state.turnTerminalStatus = null;
1075
1289
  state.livePhase = "running";
1076
1290
  state.liveRetry = null;
1077
1291
  renderConversation();
1078
- } else if (event.type === "agent_settled" || event.type === "prompt_settled") {
1079
- if (event.type === "prompt_settled") rememberTerminalPrompt(event.detail?.commandId);
1080
- state.liveRunning = false;
1081
- state.livePhase = "idle";
1292
+ } else if (event.type === "agent_start") {
1293
+ if (event.detail?.activeTurn) state.activeTurn = event.detail.activeTurn;
1294
+ state.liveRunning = true;
1295
+ state.livePhase = "running";
1082
1296
  state.liveRetry = null;
1083
1297
  renderConversation();
1298
+ } else if (event.type === "turn_settled") {
1299
+ rememberTerminalPrompt(event.detail?.commandId);
1300
+ const isActiveTurn =
1301
+ state.activeTurn?.sessionId === event.detail?.sessionId &&
1302
+ state.activeTurn?.commandId === event.detail?.commandId &&
1303
+ state.activeTurn?.epoch === event.detail?.epoch;
1304
+ if (isActiveTurn) {
1305
+ state.activeTurn = null;
1306
+ state.liveRunning = false;
1307
+ state.livePhase = "idle";
1308
+ state.liveRetry = null;
1309
+ state.turnTerminalStatus = event.detail?.outcome || null;
1310
+ }
1311
+ renderConversation();
1312
+ } else if (event.type === "agent_settled") {
1313
+ state.pendingFollowUpsReceipt = null;
1314
+ if (!state.activeTurn) {
1315
+ state.liveRunning = false;
1316
+ state.livePhase = "idle";
1317
+ state.liveRetry = null;
1318
+ }
1319
+ renderConversation();
1320
+ } else if (event.type === "prompt_settled") {
1321
+ rememberTerminalPrompt(event.detail?.commandId);
1322
+ if (state.livePhase !== "running") {
1323
+ state.liveRunning = false;
1324
+ state.livePhase = "idle";
1325
+ state.liveRetry = null;
1326
+ }
1327
+ renderConversation();
1084
1328
  } else if (event.detail?.message) {
1085
1329
  if (event.detail.message.role === "user") {
1086
1330
  state.liveMessages = state.liveMessages.filter(
@@ -1100,6 +1344,8 @@ function applyRuntimeEvent(event) {
1100
1344
  [
1101
1345
  "agent_start",
1102
1346
  "agent_settled",
1347
+ "turn_started",
1348
+ "turn_settled",
1103
1349
  "prompt_settled",
1104
1350
  "message_end",
1105
1351
  "tool_execution_end",
@@ -1160,18 +1406,33 @@ function rememberCompletedActivation(commandId) {
1160
1406
  }
1161
1407
 
1162
1408
  let eventLoopStarted = false;
1409
+ let eventLoopStopped = false;
1410
+ let activeEventReader = null;
1411
+
1412
+ window.addEventListener("pagehide", (event) => {
1413
+ // A bfcache entry resumes this same document and its event loop. Do not
1414
+ // create a second SSE connection on pageshow.
1415
+ if (event.persisted) return;
1416
+ eventLoopStopped = true;
1417
+ void activeEventReader?.cancel().catch(() => undefined);
1418
+ });
1419
+
1163
1420
  function resetLiveState() {
1164
1421
  state.liveMessages = [];
1165
1422
  state.liveRunning = false;
1423
+ state.activeTurn = null;
1424
+ state.turnCancellationPending = false;
1425
+ state.turnTerminalStatus = null;
1166
1426
  state.livePhase = "idle";
1167
1427
  state.liveRetry = null;
1428
+ state.pendingFollowUpsReceipt = null;
1168
1429
  }
1169
1430
 
1170
1431
  async function connectEvents() {
1171
1432
  if (eventLoopStarted) return;
1172
1433
  eventLoopStarted = true;
1173
1434
  let reconnectDelay = 500;
1174
- while (true) {
1435
+ while (!eventLoopStopped) {
1175
1436
  let reader = null;
1176
1437
  let recoveryAttempted = false;
1177
1438
  try {
@@ -1193,17 +1454,28 @@ async function connectEvents() {
1193
1454
  if (!response.ok || !response.body) throw new Error("event connection failed");
1194
1455
  $("connection-state").textContent = "Connected";
1195
1456
  $("connection-state").classList.remove("reconnecting");
1457
+ clearComposerFeedback("connection");
1196
1458
  reconnectDelay = 500;
1197
1459
  reader = response.body.getReader();
1460
+ activeEventReader = reader;
1198
1461
  const decoder = new TextDecoder();
1199
1462
  let buffer = "";
1463
+ let heartbeatCount = 0;
1200
1464
  while (true) {
1201
- const { done, value } = await reader.read();
1465
+ const { done, value } = await readEventChunk(reader);
1202
1466
  if (done) throw new Error("event connection closed");
1203
1467
  buffer += decoder.decode(value, { stream: true });
1204
1468
  const records = buffer.split("\n\n");
1205
1469
  buffer = records.pop() || "";
1206
1470
  for (const record of records) {
1471
+ if (record.split("\n").some((item) => item === ": heartbeat")) {
1472
+ heartbeatCount++;
1473
+ if (heartbeatCount >= HEARTBEATS_PER_SNAPSHOT) {
1474
+ heartbeatCount = 0;
1475
+ scheduleSnapshotRefresh(0);
1476
+ }
1477
+ continue;
1478
+ }
1207
1479
  const line = record.split("\n").find((item) => item.startsWith("data: "));
1208
1480
  if (!line) continue;
1209
1481
  const event = JSON.parse(line.slice(6));
@@ -1217,21 +1489,42 @@ async function connectEvents() {
1217
1489
  }
1218
1490
  } catch {
1219
1491
  await reader?.cancel().catch(() => undefined);
1492
+ if (activeEventReader === reader) activeEventReader = null;
1220
1493
  reader = null;
1494
+ if (eventLoopStopped) break;
1221
1495
  $("connection-state").textContent = "Reconnecting";
1222
1496
  $("connection-state").classList.add("reconnecting");
1223
1497
  const recovered = recoveryAttempted
1224
1498
  ? false
1225
1499
  : await refreshSnapshot({ resetCursor: true });
1226
1500
  if (!recovered) resetLiveState();
1501
+ setComposerFeedback(t("reconnectingHint"), "connection");
1227
1502
  await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
1228
1503
  reconnectDelay = Math.min(reconnectDelay * 2, 5_000);
1229
1504
  } finally {
1230
1505
  await reader?.cancel().catch(() => undefined);
1506
+ if (activeEventReader === reader) activeEventReader = null;
1231
1507
  }
1232
1508
  }
1233
1509
  }
1234
1510
 
1511
+ async function readEventChunk(reader, timeoutMs = SSE_STALE_TIMEOUT_MS) {
1512
+ let timer;
1513
+ try {
1514
+ return await Promise.race([
1515
+ reader.read(),
1516
+ new Promise((_, reject) => {
1517
+ timer = window.setTimeout(
1518
+ () => reject(new Error("event stream stalled")),
1519
+ timeoutMs,
1520
+ );
1521
+ }),
1522
+ ]);
1523
+ } finally {
1524
+ if (timer !== undefined) window.clearTimeout(timer);
1525
+ }
1526
+ }
1527
+
1235
1528
  const collapseButton = $("collapse-sidebar");
1236
1529
  const collapsedStorageKey = "openpi.sidebar-collapsed";
1237
1530
  const setSidebarCollapsed = (collapsed) => {
@@ -1384,7 +1677,15 @@ $("composer")?.addEventListener("submit", (event) => {
1384
1677
  if (state.selectedWorkspace) void sendPrompt();
1385
1678
  else void chooseWorkspace();
1386
1679
  });
1387
- $("prompt-input")?.addEventListener("input", resizePrompt);
1680
+ $("stop-turn")?.addEventListener("click", () => {
1681
+ void cancelActiveTurn();
1682
+ });
1683
+ $("prompt-input")?.addEventListener("input", () => {
1684
+ state.pendingFollowUpsReceipt = null;
1685
+ if (!state.promptAdmissionPending) clearComposerFeedback();
1686
+ resizePrompt();
1687
+ updateComposer();
1688
+ });
1388
1689
  $("prompt-input")?.addEventListener("keydown", (event) => {
1389
1690
  if (event.isComposing || event.keyCode === 229) return;
1390
1691
  if (event.key === "Enter" && !event.shiftKey) {
@@ -1394,5 +1695,6 @@ $("prompt-input")?.addEventListener("keydown", (event) => {
1394
1695
  }
1395
1696
  });
1396
1697
 
1698
+ applyThemePreference("system");
1397
1699
  applyLanguage();
1398
1700
  void connectEvents();
package/web/ui/index.html CHANGED
@@ -118,6 +118,9 @@
118
118
  <button id="send-prompt" class="send-button" type="submit" aria-label="Send" title="Send">
119
119
  <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5"></path><path d="m6 11 6-6 6 6"></path></svg>
120
120
  </button>
121
+ <button id="stop-turn" class="stop-button" type="button" aria-label="Stop turn" title="Stop turn" data-i18n-aria="stopTurn" data-i18n-title="stopTurn" hidden>
122
+ <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="1"></rect></svg>
123
+ </button>
121
124
  </div>
122
125
  <div id="composer-hint" class="composer-hint" aria-live="polite"></div>
123
126
  </form>