返回 CodeWhale
runtime_mobile.html
根目录 / crates / tui / src / runtime_mobile.html
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 <title>Codewhale Mobile</title>
7 <style>
8 :root {
9 color-scheme: dark;
10 --bg: #101214;
11 --panel: #181b1f;
12 --panel-2: #20242a;
13 --line: #323840;
14 --text: #f4f1e8;
15 --muted: #a8a49a;
16 --accent: #48c2a8;
17 --accent-2: #c89543;
18 --danger: #e86458;
19 --ok: #76c46b;
20 }
21 * { box-sizing: border-box; }
22 html, body { min-height: 100%; }
23 body {
24 margin: 0;
25 background: var(--bg);
26 color: var(--text);
27 font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
28 letter-spacing: 0;
29 }
30 header {
31 position: sticky;
32 top: 0;
33 z-index: 4;
34 display: flex;
35 align-items: center;
36 justify-content: space-between;
37 gap: 12px;
38 padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
39 background: rgba(16, 18, 20, 0.96);
40 border-bottom: 1px solid var(--line);
41 backdrop-filter: blur(12px);
42 }
43 h1 {
44 margin: 0;
45 font-size: 18px;
46 font-weight: 740;
47 }
48 main {
49 display: grid;
50 gap: 12px;
51 padding: 12px 12px calc(14px + env(safe-area-inset-bottom));
52 }
53 section {
54 border: 1px solid var(--line);
55 background: var(--panel);
56 border-radius: 8px;
57 overflow: hidden;
58 }
59 .head {
60 display: flex;
61 align-items: center;
62 justify-content: space-between;
63 gap: 8px;
64 padding: 10px 12px;
65 border-bottom: 1px solid var(--line);
66 min-height: 46px;
67 }
68 .body { padding: 12px; }
69 .grid { display: grid; gap: 10px; }
70 .row { display: flex; align-items: center; gap: 8px; }
71 .row > * { flex: 1; min-width: 0; }
72 button, input, textarea, select {
73 color: var(--text);
74 background: var(--panel-2);
75 border: 1px solid var(--line);
76 border-radius: 8px;
77 font: inherit;
78 letter-spacing: 0;
79 }
80 button {
81 min-height: 40px;
82 padding: 8px 11px;
83 font-weight: 680;
84 cursor: pointer;
85 }
86 button.primary {
87 background: #173d36;
88 border-color: #2a8b77;
89 }
90 button.warn {
91 background: #3c2b14;
92 border-color: #8b682b;
93 }
94 button.danger {
95 background: #421f1d;
96 border-color: #9b4038;
97 }
98 button:disabled {
99 cursor: not-allowed;
100 opacity: 0.55;
101 }
102 input, textarea, select {
103 width: 100%;
104 padding: 10px;
105 }
106 textarea {
107 min-height: 96px;
108 resize: vertical;
109 }
110 label.toggle {
111 display: flex;
112 align-items: center;
113 gap: 8px;
114 min-height: 40px;
115 padding: 8px 10px;
116 color: var(--muted);
117 background: var(--panel-2);
118 border: 1px solid var(--line);
119 border-radius: 8px;
120 font-size: 13px;
121 }
122 label.toggle input { width: auto; }
123 .status {
124 min-height: 38px;
125 padding: 9px 10px;
126 border-radius: 8px;
127 background: #121518;
128 border: 1px solid var(--line);
129 color: var(--muted);
130 overflow-wrap: anywhere;
131 }
132 .status.ok { color: var(--ok); }
133 .status.bad { color: var(--danger); }
134 .status.warn { color: var(--accent-2); }
135 #threads {
136 display: grid;
137 gap: 8px;
138 max-height: 260px;
139 overflow: auto;
140 }
141 .thread {
142 width: 100%;
143 padding: 10px;
144 text-align: left;
145 border-radius: 8px;
146 background: var(--panel-2);
147 }
148 .thread.active { border-color: var(--accent); }
149 .thread-title {
150 font-weight: 720;
151 white-space: nowrap;
152 overflow: hidden;
153 text-overflow: ellipsis;
154 }
155 .meta {
156 margin-top: 3px;
157 color: var(--muted);
158 font-size: 12px;
159 overflow-wrap: anywhere;
160 }
161 #events {
162 display: grid;
163 gap: 8px;
164 max-height: 54vh;
165 overflow: auto;
166 padding-right: 2px;
167 }
168 .event {
169 padding: 9px 10px;
170 border: 1px solid var(--line);
171 border-radius: 8px;
172 background: #121518;
173 white-space: pre-wrap;
174 overflow-wrap: anywhere;
175 }
176 .event.agent { border-color: #357969; }
177 .event.tool { border-color: #8b682b; }
178 .event.error { border-color: #9b4038; }
179 .event.ok { border-color: #4d8048; }
180 .approval-actions {
181 display: flex;
182 flex-wrap: wrap;
183 gap: 8px;
184 margin-top: 9px;
185 }
186 .approval-actions button {
187 flex: 0 0 auto;
188 min-width: 84px;
189 }
190 .empty {
191 color: var(--muted);
192 font-size: 13px;
193 }
194 #agents-section[hidden] { display: none; }
195 .agent-row {
196 display: grid;
197 grid-template-columns: minmax(0, auto) auto;
198 gap: 2px 8px;
199 padding: 6px 0;
200 border-bottom: 1px solid var(--line);
201 font-size: 13px;
202 }
203 .agent-row:last-child { border-bottom: 0; }
204 .agent-name { font-weight: 600; overflow-wrap: anywhere; }
205 .agent-state { color: var(--muted); text-align: right; }
206 .agent-action {
207 grid-column: 1 / -1;
208 color: var(--muted);
209 overflow-wrap: anywhere;
210 }
211 @media (min-width: 900px) {
212 main {
213 grid-template-columns: 330px minmax(0, 1fr);
214 align-items: start;
215 }
216 section.chat {
217 grid-column: 2;
218 grid-row: 1 / span 3;
219 }
220 #threads { max-height: 420px; }
221 #events { max-height: calc(100vh - 150px); }
222 }
223 </style>
224 </head>
225 <body>
226 <header>
227 <h1>Codewhale Mobile</h1>
228 <button id="refresh" title="Refresh threads">Refresh</button>
229 </header>
230
231 <main>
232 <section>
233 <div class="head">
234 <strong>Connection</strong>
235 <button id="save-token">Connect</button>
236 </div>
237 <div class="body grid">
238 <input id="token" autocomplete="off" spellcheck="false" placeholder="Runtime token (used once)">
239 <div id="conn" class="status">Not connected</div>
240 </div>
241 </section>
242
243 <section>
244 <div class="head">
245 <strong>Threads</strong>
246 <button id="new-thread" class="primary">New</button>
247 </div>
248 <div class="body"><div id="threads"></div></div>
249 </section>
250
251 <section id="agents-section" hidden>
252 <div class="head">
253 <strong>Agents</strong>
254 <span id="agent-count" class="meta"></span>
255 </div>
256 <div class="body"><div id="agents"></div></div>
257 </section>
258
259 <section class="chat">
260 <div class="head">
261 <strong id="active-title">No thread selected</strong>
262 <span id="event-count" class="meta">0 events</span>
263 </div>
264 <div class="body"><div id="events"></div></div>
265 </section>
266
267 <section>
268 <div class="head">
269 <strong>Composer</strong>
270 <button id="interrupt" class="danger">Interrupt</button>
271 </div>
272 <div class="body grid">
273 <textarea id="prompt" spellcheck="true" placeholder="Message"></textarea>
274 <div class="row">
275 <label class="toggle"><input id="allow-shell" type="checkbox"> shell</label>
276 <label class="toggle"><input id="auto-approve" type="checkbox"> auto</label>
277 <label class="toggle"><input id="remember-approval" type="checkbox"> remember</label>
278 </div>
279 <div class="row">
280 <button id="send" class="primary">Send</button>
281 <button id="steer" class="warn">Steer</button>
282 </div>
283 </div>
284 </section>
285 </main>
286
287 <script>
288 const streamMessages = __CODEWHALE_STREAM_MESSAGES__;
289 const $ = (id) => document.getElementById(id);
290 const MAX_VISIBLE_EVENTS = 100;
291 const LEGACY_RUNTIME_TOKEN_STORAGE_KEY = "codewhale_runtime_token";
292 const LEGACY_RUNTIME_TOKEN_COOKIE = "codewhale_runtime_token";
293 const SESSION_STORAGE_KEY = "codewhale_mobile_session_proof";
294 const MOBILE_REQUEST_HEADER = "X-Codewhale-Mobile-Request";
295 const MOBILE_STREAM_TICKET_QUERY = "mobile_stream_ticket";
296 const state = {
297 threadId: "",
298 activeTurnId: "",
299 source: null,
300 // Seq of the last journal event shown for this thread: the resume
301 // cursor. Frames at or below it are replays and are skipped.
302 lastSeq: 0,
303 retryDelay: 750,
304 openedAt: 0,
305 eventCount: 0,
306 requestProof: "",
307 streamTicket: "",
308 // Sub-agents of the open thread, by agent id: rebuilt from the
309 // agent.* events the stream replays, so a reconnect restores it.
310 agents: new Map()
311 };
312
313 function setStatus(message, tone = "") {
314 const el = $("conn");
315 el.textContent = message;
316 el.className = "status" + (tone ? " " + tone : "");
317 }
318
319 function token() {
320 return $("token").value.trim();
321 }
322
323 function clearLegacyRuntimeToken() {
324 localStorage.removeItem(LEGACY_RUNTIME_TOKEN_STORAGE_KEY);
325 document.cookie = LEGACY_RUNTIME_TOKEN_COOKIE + "=; Max-Age=0; Path=/; SameSite=Strict";
326 }
327
328 function saveMobileSession() {
329 if (!state.requestProof) {
330 sessionStorage.removeItem(SESSION_STORAGE_KEY);
331 return;
332 }
333 sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({
334 requestProof: state.requestProof,
335 streamTicket: state.streamTicket
336 }));
337 }
338
339 function clearMobileSession() {
340 state.requestProof = "";
341 state.streamTicket = "";
342 sessionStorage.removeItem(SESSION_STORAGE_KEY);
343 }
344
345 function loadMobileSession() {
346 try {
347 const stored = JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || "null");
348 if (stored && typeof stored.requestProof === "string") {
349 state.requestProof = stored.requestProof;
350 state.streamTicket = typeof stored.streamTicket === "string" ? stored.streamTicket : "";
351 }
352 } catch (_) {
353 clearMobileSession();
354 }
355 }
356
357 function useMobileSession(session) {
358 if (!session || typeof session.request_proof !== "string") {
359 throw new Error("Mobile session bootstrap was incomplete");
360 }
361 state.requestProof = session.request_proof;
362 state.streamTicket = typeof session.stream_ticket === "string" ? session.stream_ticket : "";
363 saveMobileSession();
364 }
365
366 function takeMobileSessionFromUrl() {
367 const hash = location.hash.startsWith("#") ? location.hash.slice(1) : "";
368 const params = new URLSearchParams(hash);
369 const requestProof = params.get("request_proof");
370 const streamTicket = params.get("stream_ticket");
371 if (!requestProof || !streamTicket) return;
372 useMobileSession({ request_proof: requestProof, stream_ticket: streamTicket });
373 params.delete("request_proof");
374 params.delete("stream_ticket");
375 const nextHash = params.toString();
376 history.replaceState(
377 null,
378 "",
379 location.pathname + location.search + (nextHash ? "#" + nextHash : "")
380 );
381 }
382
383 function headers(extra = {}) {
384 const out = Object.assign({ "Content-Type": "application/json" }, extra);
385 if (state.requestProof) out[MOBILE_REQUEST_HEADER] = state.requestProof;
386 return out;
387 }
388
389 async function api(path, options = {}) {
390 const res = await fetch(path, Object.assign({}, options, {
391 credentials: "same-origin",
392 headers: headers(options.headers || {})
393 }));
394 if (!res.ok) {
395 if (res.status === 401) clearMobileSession();
396 let detail = await res.text();
397 try {
398 const parsed = JSON.parse(detail);
399 detail = parsed.error?.message || detail;
400 } catch (_) {}
401 throw new Error(detail || ("HTTP " + res.status));
402 }
403 if (res.status === 204) return null;
404 return res.json();
405 }
406
407 async function connectMobileSession() {
408 const runtimeToken = token();
409 if (!runtimeToken) throw new Error("Enter a Runtime token to connect once");
410 let res;
411 try {
412 res = await fetch("/__codewhale/mobile/session", {
413 method: "POST",
414 credentials: "same-origin",
415 headers: { "Authorization": "Bearer " + runtimeToken }
416 });
417 } finally {
418 $("token").value = "";
419 }
420 if (!res.ok) {
421 let detail = await res.text();
422 try {
423 const parsed = JSON.parse(detail);
424 detail = parsed.error?.message || detail;
425 } catch (_) {}
426 throw new Error(detail || ("HTTP " + res.status));
427 }
428 useMobileSession(await res.json());
429 }
430
431 async function refreshStreamTicket() {
432 const result = await api("/__codewhale/mobile/stream-ticket", {
433 method: "POST",
434 body: "{}"
435 });
436 if (!result || typeof result.stream_ticket !== "string") {
437 throw new Error("Mobile stream ticket was unavailable");
438 }
439 state.streamTicket = result.stream_ticket;
440 saveMobileSession();
441 return state.streamTicket;
442 }
443
444 async function takeStreamTicket() {
445 if (!state.requestProof) return "";
446 const ticket = state.streamTicket || await refreshStreamTicket();
447 state.streamTicket = "";
448 saveMobileSession();
449 return ticket;
450 }
451
452 function escapeHtml(raw) {
453 return String(raw).replace(/[&<>"']/g, (char) => ({
454 "&": "&amp;",
455 "<": "&lt;",
456 ">": "&gt;",
457 "\"": "&quot;",
458 "'": "&#039;"
459 }[char]));
460 }
461
462 function eventPayload(data) {
463 return data && typeof data === "object" && "payload" in data ? data.payload : data;
464 }
465
466 // The name every Codewhale surface shows for an agent (its workflow
467 // label or dispatch name), never its internal id. Hosts that predate the
468 // name get an ordinal instead.
469 function agentName(payload) {
470 const known = state.agents.get(payload.agent_id);
471 if (payload.agent_name) return payload.agent_name;
472 if (known && known.name) return known.name;
473 return "agent " + (known ? known.order : state.agents.size + 1);
474 }
475
476 // The first line of prose in a result summary, not its heading.
477 function resultHeadline(text) {
478 const summary = String(text || "").replace(/^Sub-agent .*? (completed|failed|cancelled|interrupted|budget_exhausted|settled \(outcome unconfirmed\)): /, "");
479 const line = summary.split("\n").map((part) => part.trim())
480 .find((part) => part && !part.startsWith("#") && !part.startsWith("<codewhale:"));
481 return line || "";
482 }
483
484 function noteAgentEvent(name, data) {
485 if (!name.startsWith("agent.")) return;
486 const payload = eventPayload(data) || {};
487 const id = payload.agent_id;
488 if (!id) return;
489 let agent = state.agents.get(id);
490 if (!agent) {
491 agent = { name: "", state: "", action: "", order: state.agents.size + 1 };
492 state.agents.set(id, agent);
493 }
494 if (payload.agent_name) agent.name = payload.agent_name;
495 if (name === "agent.spawned") {
496 agent.state = "running";
497 agent.action = "starting";
498 } else if (name === "agent.progress") {
499 agent.state = "running";
500 agent.action = [String(payload.worker_status || "").replace(/_/g, " "),
501 payload.step ? "step " + payload.step : ""].filter(Boolean).join(" · ");
502 } else if (name === "agent.completed") {
503 agent.state = String(payload.worker_status || "settled").replace(/_/g, " ");
504 agent.action = resultHeadline((payload.item || {}).summary);
505 }
506 renderAgents();
507 }
508
509 function renderAgents() {
510 const list = $("agents");
511 list.innerHTML = "";
512 let running = 0;
513 for (const [id, agent] of state.agents) {
514 if (agent.state === "running") running += 1;
515 const row = document.createElement("div");
516 row.className = "agent-row";
517 row.innerHTML =
518 "<span class='agent-name'>" + escapeHtml(agentName({ agent_id: id })) + "</span>" +
519 "<span class='agent-state'>" + escapeHtml(agent.state) + "</span>" +
520 (agent.action ? "<span class='agent-action'>" + escapeHtml(agent.action) + "</span>" : "");
521 list.appendChild(row);
522 }
523 $("agents-section").hidden = state.agents.size === 0;
524 $("agent-count").textContent = state.agents.size
525 ? running + " running · " + state.agents.size + " total"
526 : "";
527 }
528
529 function eventText(name, data) {
530 const payload = eventPayload(data) || {};
531 if (name === "agent.spawned") return agentName(payload) + " started";
532 if (name === "agent.progress") {
533 return agentName(payload) + ": " + String(payload.worker_status || "working").replace(/_/g, " ") +
534 (payload.step ? " · step " + payload.step : "");
535 }
536 if (name === "agent.completed") {
537 const headline = resultHeadline((payload.item || {}).summary);
538 return agentName(payload) + " " + String(payload.worker_status || "settled").replace(/_/g, " ") +
539 (headline ? ": " + headline : "");
540 }
541 if (name === "item.delta") return payload.delta || "";
542 if (name === "item.started") {
543 const tool = payload.tool;
544 if (tool) return "Tool started: " + tool.name + "\n" + JSON.stringify(tool.input || {}, null, 2);
545 }
546 if (name === "item.completed" || name === "item.failed") {
547 const item = payload.item || {};
548 return (name === "item.completed" ? "Completed: " : "Failed: ") + (item.summary || item.kind || "");
549 }
550 if (name === "approval.required") {
551 return "Approval required: " + (payload.tool_name || "") + "\n" + (payload.description || "");
552 }
553 if (name === "approval.decided") {
554 return "Approval " + (payload.decision || "decided") + ": " + (payload.approval_id || "");
555 }
556 if (name === "approval.timeout") {
557 return "Approval timed out: " + (payload.approval_id || "");
558 }
559 if (name === "sandbox.denied") {
560 return "Sandbox denied: " + (payload.tool_name || "") + "\n" + (payload.reason || "");
561 }
562 if (name === "turn.lifecycle") return "Turn status: " + (payload.status || "");
563 if (name === "turn.completed") return "Turn completed";
564 return JSON.stringify(payload, null, 2);
565 }
566
567 function approvalInfo(name, data) {
568 if (name !== "approval.required") return null;
569 const payload = eventPayload(data) || {};
570 const approvalId = payload.approval_id || payload.id;
571 if (!approvalId) return null;
572 return { id: approvalId };
573 }
574
575 function pruneVisibleEvents() {
576 const events = $("events");
577 while (events.childElementCount > MAX_VISIBLE_EVENTS) {
578 events.firstElementChild.remove();
579 }
580 }
581
582 async function decideApproval(approvalId, decision, container) {
583 for (const button of container.querySelectorAll("button")) button.disabled = true;
584 const remember = $("remember-approval").checked;
585 try {
586 const result = await api("/v1/approvals/" + encodeURIComponent(approvalId), {
587 method: "POST",
588 body: JSON.stringify({ decision, remember })
589 });
590 const decided = result?.decision ?? decision;
591 container.innerHTML = "<span class='meta'>Decision sent: " + escapeHtml(decided) + "</span>";
592 } catch (err) {
593 for (const button of container.querySelectorAll("button")) button.disabled = false;
594 throw err;
595 }
596 }
597
598 function appendEvent(name, data) {
599 noteAgentEvent(name, data);
600 const text = eventText(name, data);
601 if (!text) return;
602 const item = document.createElement("div");
603 item.className = "event";
604 if (name === "item.delta") item.classList.add("agent");
605 if (name === "item.started" || name.includes("tool")) item.classList.add("tool");
606 if (name.includes("failed") || name.includes("denied") || name.includes("timeout")) item.classList.add("error");
607 if (name === "turn.completed" || name === "approval.decided") item.classList.add("ok");
608 if (name.startsWith("agent.")) item.classList.add("tool");
609 item.innerHTML = "<div class='meta'>" + escapeHtml(name) + "</div>" + escapeHtml(text);
610
611 const approval = approvalInfo(name, data);
612 if (approval) {
613 const actions = document.createElement("div");
614 actions.className = "approval-actions";
615 const allow = document.createElement("button");
616 allow.className = "primary";
617 allow.textContent = "Allow";
618 allow.onclick = () => decideApproval(approval.id, "allow", actions)
619 .catch((err) => setStatus(err.message, "bad"));
620 const deny = document.createElement("button");
621 deny.className = "danger";
622 deny.textContent = "Deny";
623 deny.onclick = () => decideApproval(approval.id, "deny", actions)
624 .catch((err) => setStatus(err.message, "bad"));
625 actions.appendChild(allow);
626 actions.appendChild(deny);
627 item.appendChild(actions);
628 }
629
630 $("events").appendChild(item);
631 pruneVisibleEvents();
632 state.eventCount += 1;
633 const visible = Math.min(state.eventCount, MAX_VISIBLE_EVENTS);
634 $("event-count").textContent = visible + " of " + state.eventCount + " events";
635 $("events").scrollTop = $("events").scrollHeight;
636 }
637
638 async function loadThreads() {
639 const threads = await api("/v1/threads/summary?limit=60&include_archived=false");
640 const list = $("threads");
641 list.innerHTML = "";
642 if (!threads || !threads.length) {
643 list.innerHTML = "<div class='empty'>No active threads.</div>";
644 return;
645 }
646 for (const thread of threads) {
647 const el = document.createElement("button");
648 el.className = "thread" + (thread.id === state.threadId ? " active" : "");
649 el.innerHTML =
650 "<div class='thread-title'>" + escapeHtml(thread.title || thread.id) + "</div>" +
651 "<div class='meta'>" + escapeHtml(thread.mode || "") + " / " +
652 escapeHtml(thread.model || "") + "</div>";
653 el.onclick = () => selectThread(thread.id, thread.title || thread.id);
654 list.appendChild(el);
655 }
656 }
657
658 async function newThread() {
659 const thread = await api("/v1/threads", {
660 method: "POST",
661 body: JSON.stringify({
662 mode: "agent",
663 allow_shell: $("allow-shell").checked,
664 trust_mode: false,
665 auto_approve: $("auto-approve").checked
666 })
667 });
668 await loadThreads();
669 selectThread(thread.id, thread.title || thread.id);
670 }
671
672 async function selectThread(id, title) {
673 state.threadId = id;
674 state.activeTurnId = "";
675 state.lastSeq = 0;
676 state.retryDelay = 750;
677 state.openedAt = 0;
678 state.eventCount = 0;
679 $("event-count").textContent = "0 events";
680 $("active-title").textContent = title || id;
681 $("events").innerHTML = "";
682 state.agents.clear();
683 renderAgents();
684 if (state.source) state.source.close();
685 await openThreadEvents(id);
686 await loadThreads();
687 }
688
689 // What the Runtime said when it ended the stream on purpose (`stream.end`).
690 function streamEndStatus(reason) {
691 switch (reason) {
692 case "replay_failed":
693 return streamMessages.replay_failed;
694 case "catch_up_failed":
695 return streamMessages.catch_up_failed;
696 case "runtime_shutdown":
697 return streamMessages.runtime_shutdown;
698 default:
699 return streamMessages.ended;
700 }
701 }
702
703 // Reconnect from `cursor` (undefined: nothing shown yet, open fresh) after
704 // a bounded backoff. The backoff resets only after a stream stayed open a
705 // while: a Runtime that opens and at once ends the stream (a history it
706 // cannot read) is retried ever more slowly, not hammered. A Runtime that is not
707 // reachable yet fails the stream-ticket fetch itself (a network TypeError):
708 // keep trying. An HTTP refusal (for example an expired session) is final.
709 function scheduleResume(id, cursor) {
710 if (state.openedAt && Date.now() - state.openedAt > 10000) state.retryDelay = 750;
711 state.openedAt = 0;
712 const delay = state.retryDelay;
713 state.retryDelay = Math.min(state.retryDelay * 2, 15000);
714 window.setTimeout(() => {
715 if (state.threadId !== id) return;
716 openThreadEvents(id, cursor).catch((err) => {
717 if (state.threadId !== id) return;
718 if (err instanceof TypeError) {
719 setStatus(streamMessages.reconnecting, "warn");
720 scheduleResume(id, cursor);
721 } else {
722 setStatus(err.message, "bad");
723 }
724 });
725 }, delay);
726 }
727
728 // `resumeFrom` undefined opens the newest tail; a number resumes after it,
729 // so a reconnect never replays (and duplicates) what is already shown.
730 async function openThreadEvents(id, resumeFrom) {
731 const streamTicket = await takeStreamTicket();
732 const query = new URLSearchParams(
733 resumeFrom === undefined
734 ? { since_seq: "0", replay_limit: String(MAX_VISIBLE_EVENTS) }
735 : { since_seq: String(resumeFrom) }
736 );
737 if (streamTicket) query.set(MOBILE_STREAM_TICKET_QUERY, streamTicket);
738 const source = new EventSource(
739 "/v1/threads/" + encodeURIComponent(id) + "/events?" + query.toString()
740 );
741 state.source = source;
742 const names = [
743 "thread.started",
744 "turn.started",
745 "turn.lifecycle",
746 "turn.steered",
747 "turn.interrupt_requested",
748 "turn.completed",
749 "item.started",
750 "item.delta",
751 "item.completed",
752 "item.failed",
753 "approval.required",
754 "approval.decided",
755 "approval.timeout",
756 "sandbox.denied",
757 "agent.spawned",
758 "agent.progress",
759 "agent.completed"
760 ];
761 for (const name of names) {
762 source.addEventListener(name, (ev) => {
763 if (state.source !== source) return;
764 let data = {};
765 try { data = JSON.parse(ev.data || "{}"); } catch (_) {}
766 if (Number.isSafeInteger(data.seq)) {
767 if (data.seq <= state.lastSeq) return;
768 state.lastSeq = data.seq;
769 }
770 if (data.turn_id) state.activeTurnId = data.turn_id;
771 appendEvent(name, data);
772 });
773 }
774 // The Runtime ended the stream on purpose; `last_seq` is where to resume.
775 source.addEventListener("stream.end", (ev) => {
776 if (state.source !== source || state.threadId !== id) return;
777 source.close();
778 let data = {};
779 try { data = JSON.parse(ev.data || "{}"); } catch (_) {}
780 if (Number.isSafeInteger(data.last_seq)) {
781 state.lastSeq = Math.max(state.lastSeq, data.last_seq);
782 }
783 if (data.retryable !== true) {
784 setStatus(streamMessages.closed, "bad");
785 return;
786 }
787 setStatus(streamEndStatus(data.reason), "warn");
788 scheduleResume(id, state.lastSeq);
789 });
790 // A Runtime that cannot serve this thread answers and at once ends the
791 // stream; claim "Connected" only once the stream has stayed open.
792 source.onopen = () => {
793 state.openedAt = Date.now();
794 window.setTimeout(() => {
795 if (state.source === source && source.readyState === EventSource.OPEN) {
796 setStatus(streamMessages.connected, "ok");
797 }
798 }, 400);
799 };
800 // No `stream.end`: the connection dropped (network, proxy, or a Runtime
801 // that exited without warning). Resume from what is already shown.
802 source.onerror = () => {
803 if (state.source !== source || state.threadId !== id) return;
804 source.close();
805 setStatus(streamMessages.reconnecting, "warn");
806 scheduleResume(id, state.lastSeq > 0 ? state.lastSeq : resumeFrom);
807 };
808 }
809
810 async function sendPrompt() {
811 if (!state.threadId) await newThread();
812 const prompt = $("prompt").value.trim();
813 if (!prompt) return;
814 const res = await api("/v1/threads/" + encodeURIComponent(state.threadId) + "/turns", {
815 method: "POST",
816 body: JSON.stringify({
817 prompt,
818 allow_shell: $("allow-shell").checked,
819 trust_mode: false,
820 auto_approve: $("auto-approve").checked
821 })
822 });
823 state.activeTurnId = res.turn?.id || state.activeTurnId;
824 $("prompt").value = "";
825 await loadThreads();
826 }
827
828 async function steerTurn() {
829 if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
830 const prompt = $("prompt").value.trim();
831 if (!prompt) return;
832 await api(
833 "/v1/threads/" + encodeURIComponent(state.threadId) +
834 "/turns/" + encodeURIComponent(state.activeTurnId) + "/steer",
835 { method: "POST", body: JSON.stringify({ prompt }) }
836 );
837 $("prompt").value = "";
838 }
839
840 async function interruptTurn() {
841 if (!state.threadId || !state.activeTurnId) throw new Error("No active turn");
842 await api(
843 "/v1/threads/" + encodeURIComponent(state.threadId) +
844 "/turns/" + encodeURIComponent(state.activeTurnId) + "/interrupt",
845 { method: "POST", body: "{}" }
846 );
847 }
848
849 async function boot() {
850 clearLegacyRuntimeToken();
851 loadMobileSession();
852 takeMobileSessionFromUrl();
853 $("save-token").onclick = async () => {
854 await connectMobileSession();
855 await loadThreads().catch((err) => setStatus(err.message, "bad"));
856 };
857 $("refresh").onclick = () => loadThreads().catch((err) => setStatus(err.message, "bad"));
858 $("new-thread").onclick = () => newThread().catch((err) => setStatus(err.message, "bad"));
859 $("send").onclick = () => sendPrompt().catch((err) => setStatus(err.message, "bad"));
860 $("steer").onclick = () => steerTurn().catch((err) => setStatus(err.message, "bad"));
861 $("interrupt").onclick = () => interruptTurn().catch((err) => setStatus(err.message, "bad"));
862 await loadThreads();
863 setStatus(streamMessages.connected, "ok");
864 }
865
866 boot().catch((err) => setStatus(err.message, "bad"));
867 </script>
868 </body>
869 </html>
870
870 lines HTML