@lelouchhe/webagent 0.6.0 → 0.8.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.
@@ -0,0 +1 @@
1
+ var s=Object.freeze({OK:200,CREATED:201,ACCEPTED:202,NO_CONTENT:204,BAD_REQUEST:400,UNAUTHORIZED:401,FORBIDDEN:403,NOT_FOUND:404,CONFLICT:409,GONE:410,PAYLOAD_TOO_LARGE:413,UNPROCESSABLE_CONTENT:422,INTERNAL_SERVER_ERROR:500,NOT_IMPLEMENTED:501,SERVICE_UNAVAILABLE:503});var c="wa_token";async function p(n,a={}){let i=a.fetch??fetch,o=n.trim();if(!o)return{ok:!1,error:"Token is required"};let r;try{r=await i("/api/v1/auth/verify",{method:"GET",headers:{Authorization:`Bearer ${o}`}})}catch(e){return{ok:!1,error:`Network error: ${e instanceof Error?e.message:String(e)}`}}if(r.status===s.OK){let e;try{e=await r.json()}catch{return{ok:!1,error:"Server returned invalid response"}}if(!e.ok||typeof e.name!="string"||typeof e.scope!="string")return{ok:!1,error:"Server returned malformed response"};try{localStorage.setItem(c,o)}catch(t){return{ok:!1,error:`Could not store token: ${t instanceof Error?t.message:String(t)}`}}return{ok:!0,name:e.name,scope:e.scope}}return r.status===s.UNAUTHORIZED?{ok:!1,error:"Invalid or rejected token"}:{ok:!1,error:`Unexpected response (status ${r.status})`}}export{s as a,c as b,p as c};
@@ -1 +1 @@
1
- import{a as r,b as i}from"./chunk.D4ZYHJAM.js";import"./chunk.AJZBJBMO.js";localStorage.getItem(r)&&location.replace("/");var s=document.getElementById("login-form"),n=document.getElementById("token-input"),o=document.getElementById("error"),t=document.getElementById("submit-btn");function u(e){o&&(o.textContent=e,o.style.display=e?"block":"none")}s&&n&&t&&(s.addEventListener("submit",e=>{e.preventDefault(),u(""),t.disabled=!0,t.textContent="Verifying\u2026",(async()=>{try{let l=await i(n.value);l.ok?location.replace("/"):u(l.error)}finally{t.disabled=!1,t.textContent="Sign in"}})()}),n.focus());
1
+ import{b as r,c as i}from"./chunk.UMQMOGWO.js";import"./chunk.AJZBJBMO.js";localStorage.getItem(r)&&location.replace("/");var s=document.getElementById("login-form"),n=document.getElementById("token-input"),o=document.getElementById("error"),t=document.getElementById("submit-btn");function u(e){o&&(o.textContent=e,o.style.display=e?"block":"none")}s&&n&&t&&(s.addEventListener("submit",e=>{e.preventDefault(),u(""),t.disabled=!0,t.textContent="Verifying\u2026",(async()=>{try{let l=await i(n.value);l.ok?location.replace("/"):u(l.error)}finally{t.disabled=!1,t.textContent="Sign in"}})()}),n.focus());
@@ -0,0 +1 @@
1
+ import{b as g,d as h,m as y,o as T,p as C}from"./chunk.CT5WBNGZ.js";import{a as p}from"./chunk.UMQMOGWO.js";import"./chunk.AJZBJBMO.js";var $=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function f(t){return String(t).padStart(2,"0")}function E(t,n){if(!t)return"";let s=new Date(t),i=s.getTime();if(isNaN(i))return"";let l=Math.floor((n.getTime()-i)/1e3);if(l<60)return"just now";let c=Math.floor(l/60);if(c<60)return`${c}m ago`;let o=Math.floor(c/60);if(o<24)return`${o}h ago`;let e=Math.floor(o/24);if(e<7)return`${e}d ago`;let m=s.getUTCFullYear()===n.getUTCFullYear(),r=$[s.getUTCMonth()],a=s.getUTCDate();return m?`${r} ${a}`:`${r} ${a}, ${s.getUTCFullYear()}`}function _(t){if(!t)return"";let n=new Date(t);return isNaN(n.getTime())?"":`${n.getUTCFullYear()}-${f(n.getUTCMonth()+1)}-${f(n.getUTCDate())} ${f(n.getUTCHours())}:${f(n.getUTCMinutes())} UTC`}function S(t){let n=/^\/api\/v1\/sessions\/[^/]+\/attachments\/([A-Za-z0-9._-]+)(?:\?.*)?$/;return s=>{let i=n.exec(s);return i?`/s/${encodeURIComponent(t)}/attachments/${i[1]}`:s}}function w(t){if(typeof t.data=="string")try{return JSON.parse(t.data)}catch{return{}}return t.data}function x(t,n,s,i){if(t.type!=="assistant_message"||n?.type!=="assistant_message"||t.seq!==n.seq+1||!s?.classList.contains("assistant"))return!1;let l=(s.getAttribute("data-raw")??"")+(typeof i.text=="string"?i.text:"");return s.setAttribute("data-raw",l),C(s,l,void 0,!0),g(s),!0}function v(t,n,s){let i=new Set;for(let r of t)if(r.type==="permission_response")try{let a=JSON.parse(r.data);typeof a.requestId=="string"&&i.add(a.requestId)}catch{}let l=new Map,c=new Map,o=null,e=null,m={rewriteAttachmentSrc:S(s),enhanceMarkdown:g,findToolCallEl:r=>l.get(r)??null,findPermissionEl:r=>c.get(r)??null,findBashEl:()=>o,isPermissionResolved:r=>i.has(r),finalAnswerToolText:null};for(let r=0;r<t.length;r++){let a=t[r];if(!y(a.type))continue;let u=w(a),A=r>0?t[r-1]:null,M=n.lastElementChild;if(x(a,A,M,u)){e=null;continue}m.finalAnswerToolText=a.type==="assistant_message"?e:null;let d=T(a.type,u,m);d?(n.appendChild(d),a.type==="tool_call"&&typeof u.id=="string"?l.set(u.id,d):a.type==="permission_request"&&typeof u.requestId=="string"?c.set(u.requestId,d):a.type==="bash_command"&&(o=d)):a.type==="bash_result"&&(o=null),a.type==="tool_call_update"?e=h(typeof u.status=="string"?u.status:"",Array.isArray(u.content)?u.content:void 0):e=null}}async function U(){try{let e=localStorage.getItem("theme")??"auto";document.documentElement.setAttribute("data-theme",e)}catch{}let t=/^\/s\/([A-Za-z0-9_-]{24})(?:[/?#]|$)/.exec(location.pathname);if(!t){document.body.textContent="invalid share URL";return}let n=t[1],s=document.getElementById("session-info"),i=document.getElementById("messages"),l=document.querySelector(".share-footer-author"),c=document.querySelector(".share-footer-meta");if(!i)return;let o;try{let e=await fetch(`/api/v1/shared/${encodeURIComponent(n)}/events`,{headers:{Accept:"application/json"},credentials:"omit"});if(e.status===p.GONE){document.body.innerHTML="<div class='share-gone'>\u6B64\u94FE\u63A5\u5DF2\u64A4\u9500\u6216\u8FC7\u671F\u3002</div>";return}if(!e.ok){document.body.textContent=`error ${e.status}`;return}o=await e.json()}catch(e){document.body.textContent=`failed to load: ${String(e)}`;return}if(document.title=o.share.session_title?`${o.share.session_title} \u2014 shared`:"shared session",s&&(s.textContent=o.share.session_title??"(untitled)"),l){let e=o.share.display_name??"";l.textContent=e?`by ${e}`:""}if(v(o.events,i,n),c){let e=o.share.shared_at??o.share.created_at;c.textContent=E(e,new Date),c instanceof HTMLElement&&(c.title=_(e))}}U();
package/dist/login.html CHANGED
@@ -17,7 +17,7 @@
17
17
  media="(prefers-color-scheme: light)"
18
18
  />
19
19
  <script src="/theme-init.js"></script>
20
- <link rel="stylesheet" href="/styles.01ky3cex.css" />
20
+ <link rel="stylesheet" href="/styles.00etlpgs.css" />
21
21
  </head>
22
22
  <body class="login-page">
23
23
  <div class="login-card">
@@ -44,6 +44,6 @@
44
44
  <code>webagent --create-token &lt;name&gt;</code> on the server.
45
45
  </p>
46
46
  </div>
47
- <script type="module" src="/js/login.PYIK52HN.js"></script>
47
+ <script type="module" src="/js/login.2WA6DTGM.js"></script>
48
48
  </body>
49
49
  </html>
@@ -16,10 +16,10 @@
16
16
  media="(prefers-color-scheme: light)"
17
17
  />
18
18
  <meta name="robots" content="noindex, nofollow" />
19
- <link rel="stylesheet" href="/s/_/styles.01ky3cex.css" />
19
+ <link rel="stylesheet" href="/s/_/styles.00etlpgs.css" />
20
20
  <link rel="stylesheet" href="/s/_/share-viewer.00gubshk.css" />
21
- <link rel="modulepreload" href="/s/_/chunk.ON2W5X2T.js">
22
- <link rel="modulepreload" href="/s/_/chunk.D4ZYHJAM.js">
21
+ <link rel="modulepreload" href="/s/_/chunk.CT5WBNGZ.js">
22
+ <link rel="modulepreload" href="/s/_/chunk.UMQMOGWO.js">
23
23
  <link rel="modulepreload" href="/s/_/chunk.AJZBJBMO.js">
24
24
  </head>
25
25
  <body class="share-viewer">
@@ -49,6 +49,6 @@
49
49
  >
50
50
  </div>
51
51
  </footer>
52
- <script type="module" src="/s/_/viewer.VY4PO2O5.js"></script>
52
+ <script type="module" src="/s/_/viewer.CVWXSKJM.js"></script>
53
53
  </body>
54
54
  </html>
@@ -84,6 +84,32 @@ body {
84
84
  font-weight: bold;
85
85
  color: var(--green);
86
86
  }
87
+ #inbox-btn {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ gap: 4px;
91
+ min-height: 28px;
92
+ padding: 2px 6px;
93
+ margin-left: -6px;
94
+ border: 0;
95
+ background: transparent;
96
+ color: inherit;
97
+ font: inherit;
98
+ cursor: pointer;
99
+ }
100
+ #inbox-btn:hover .logo,
101
+ #inbox-btn:focus-visible .logo {
102
+ color: var(--text);
103
+ }
104
+ #inbox-btn:focus-visible {
105
+ outline: 1px solid var(--text-dim);
106
+ outline-offset: 1px;
107
+ }
108
+ #inbox-count {
109
+ color: var(--red);
110
+ font-size: 13px;
111
+ font-weight: 600;
112
+ }
87
113
  .header-side {
88
114
  min-width: 0;
89
115
  display: flex;
@@ -312,6 +338,22 @@ body {
312
338
  font-weight: normal;
313
339
  }
314
340
 
341
+ .msg.assistant .subagent-result {
342
+ color: var(--text-dim);
343
+ font-size: 14px;
344
+ margin: 2px 0;
345
+ }
346
+ .msg.assistant .subagent-result summary {
347
+ cursor: pointer;
348
+ }
349
+ .msg.assistant .subagent-result-content {
350
+ color: var(--text);
351
+ margin: 4px 0 4px 16px;
352
+ }
353
+ .msg.assistant .assistant-continuation {
354
+ margin-top: 4px;
355
+ }
356
+
315
357
  /* Thinking */
316
358
  .thinking {
317
359
  color: var(--text-dim);
@@ -494,6 +536,12 @@ body {
494
536
  margin-bottom: 2px;
495
537
  cursor: pointer;
496
538
  }
539
+ .plan-summary::marker {
540
+ content: "";
541
+ }
542
+ .plan-summary::-webkit-details-marker {
543
+ display: none;
544
+ }
497
545
  .plan-counts {
498
546
  color: var(--text-dim);
499
547
  font-weight: normal;
@@ -505,6 +553,28 @@ body {
505
553
  .plan-entry {
506
554
  padding: 1px 0;
507
555
  }
556
+ .plan-status-pending {
557
+ color: var(--text-dim);
558
+ }
559
+ .plan-status-in_progress .plan-symbol,
560
+ .plan-counts .plan-status-in_progress {
561
+ color: var(--blue);
562
+ }
563
+ .plan-status-in_progress .plan-symbol {
564
+ display: inline-block;
565
+ background: var(--accent-soft);
566
+ border-radius: 2px;
567
+ }
568
+ .plan-status-completed .plan-symbol,
569
+ .plan-counts .plan-status-completed {
570
+ color: var(--green);
571
+ }
572
+ .plan-status-completed .plan-content {
573
+ color: var(--text-dim);
574
+ }
575
+ .plan-status-unknown {
576
+ color: var(--text-dim);
577
+ }
508
578
 
509
579
  /* Permission dialog */
510
580
  .permission {
@@ -795,6 +865,30 @@ body {
795
865
  }
796
866
  }
797
867
 
868
+ /* Panels attached to the input */
869
+ .input-panel {
870
+ background: var(--surface);
871
+ border: 1px solid var(--border);
872
+ border-bottom: none;
873
+ max-height: 220px;
874
+ }
875
+ #plan-panel {
876
+ flex: 0 0 auto;
877
+ }
878
+ #plan-panel[hidden] {
879
+ display: none;
880
+ }
881
+ #plan-panel > .plan-summary {
882
+ margin: 0;
883
+ padding: 6px 16px;
884
+ }
885
+ #plan-panel > .plan-entries {
886
+ max-height: 180px;
887
+ margin: 0;
888
+ padding: 0 16px 6px;
889
+ overflow-y: auto;
890
+ }
891
+
798
892
  /* Slash command autocomplete */
799
893
  #slash-menu {
800
894
  display: none;
@@ -802,10 +896,6 @@ body {
802
896
  bottom: 100%;
803
897
  left: 0;
804
898
  right: 0;
805
- background: var(--surface);
806
- border: 1px solid var(--border);
807
- border-bottom: none;
808
- max-height: 220px;
809
899
  overflow-y: auto;
810
900
  z-index: 10;
811
901
  }
package/dist/sw.js CHANGED
@@ -79,25 +79,35 @@ self.addEventListener("notificationclick", (e) => {
79
79
  e.notification.close();
80
80
 
81
81
  const data = e.notification.data || {};
82
- // For inbox messages, route to the bound session (if set) or root.
83
- // For session events, route to the session.
84
82
  const sessionId = data.sessionId;
85
- const urlHash = sessionId ? `/#${sessionId}` : "/";
83
+ const messageId = data.messageId;
84
+ const target = sessionId
85
+ ? { type: "navigate", sessionId }
86
+ : messageId
87
+ ? { type: "navigate", messageId }
88
+ : { type: "navigate" };
89
+ const targetUrl = sessionId
90
+ ? `/#${encodeURIComponent(sessionId)}`
91
+ : messageId
92
+ ? `/?message=${encodeURIComponent(messageId)}`
93
+ : "/";
86
94
 
87
95
  e.waitUntil(
88
96
  self.clients
89
97
  .matchAll({ type: "window", includeUncontrolled: true })
90
- .then((clients) => {
91
- // Focus existing window if open
98
+ .then(async (clients) => {
92
99
  for (const client of clients) {
93
100
  if (client.url.includes(self.location.origin)) {
94
- client.focus();
95
- client.postMessage({ type: "navigate", sessionId });
101
+ try {
102
+ await client.focus();
103
+ } catch {
104
+ // Navigation still works if the browser refuses focus.
105
+ }
106
+ client.postMessage(target);
96
107
  return;
97
108
  }
98
109
  }
99
- // Otherwise open a new window
100
- return self.clients.openWindow(urlHash);
110
+ return self.clients.openWindow(targetUrl);
101
111
  }),
102
112
  );
103
113
  });
@@ -0,0 +1,27 @@
1
+ function splitFirstToken(text) {
2
+ const boundary = text.search(/\s/);
3
+ if (boundary < 0)
4
+ return { token: text, remainder: "" };
5
+ return {
6
+ token: text.slice(0, boundary),
7
+ remainder: text.slice(boundary),
8
+ };
9
+ }
10
+ export function resolveAgentCommand(text, commands) {
11
+ if (!text.startsWith("//"))
12
+ return null;
13
+ const { token, remainder } = splitFirstToken(text);
14
+ const inputName = token.slice(2);
15
+ if (!inputName)
16
+ return null;
17
+ const command = commands.find((candidate) => candidate.name.toLowerCase() === inputName.toLowerCase());
18
+ if (!command)
19
+ return null;
20
+ return {
21
+ command: token,
22
+ agentText: `/${command.name}${remainder}`,
23
+ };
24
+ }
25
+ export function agentCommandToken(text) {
26
+ return splitFirstToken(text).token || text;
27
+ }
package/lib/bridge.js CHANGED
@@ -15,6 +15,7 @@ export class AgentBridge extends EventEmitter {
15
15
  pendingAborts = new Map();
16
16
  deadReason = null;
17
17
  stderrTail = "";
18
+ closedProcesses = new WeakSet();
18
19
  agentCmd;
19
20
  reloading = false;
20
21
  attachmentDispatcher = null;
@@ -60,6 +61,9 @@ export class AgentBridge extends EventEmitter {
60
61
  `\nCheck '${this.agentCmd}' is properly configured (e.g. authenticated).`;
61
62
  this.markAgentDead(reason);
62
63
  });
64
+ proc.once("close", () => {
65
+ this.closedProcesses.add(proc);
66
+ });
63
67
  proc.on("error", (err) => {
64
68
  if (this.reloading)
65
69
  return;
@@ -156,7 +160,10 @@ export class AgentBridge extends EventEmitter {
156
160
  });
157
161
  return result.configOptions;
158
162
  }
159
- async prompt(sessionId, text, attachments) {
163
+ async prompt(sessionId, text, attachments,
164
+ /** Turn identity echoed back on this prompt's terminal event, so a
165
+ * completion that outlives its turn can be told apart from the live one. */
166
+ promptId) {
160
167
  if (this.deadReason) {
161
168
  this.emit("event", {
162
169
  type: "error",
@@ -198,6 +205,7 @@ export class AgentBridge extends EventEmitter {
198
205
  type: "prompt_done",
199
206
  sessionId,
200
207
  stopReason: result.stopReason ?? "end_turn",
208
+ ...(promptId ? { promptId } : {}),
201
209
  });
202
210
  }
203
211
  catch (err) {
@@ -211,6 +219,7 @@ export class AgentBridge extends EventEmitter {
211
219
  type: "prompt_done",
212
220
  sessionId,
213
221
  stopReason: "cancelled",
222
+ ...(promptId ? { promptId } : {}),
214
223
  });
215
224
  return;
216
225
  }
@@ -218,6 +227,7 @@ export class AgentBridge extends EventEmitter {
218
227
  type: "error",
219
228
  sessionId,
220
229
  message,
230
+ ...(promptId ? { promptId } : {}),
221
231
  });
222
232
  }
223
233
  finally {
@@ -259,6 +269,7 @@ export class AgentBridge extends EventEmitter {
259
269
  });
260
270
  abort(new Error(reason));
261
271
  }
272
+ this.emit("event", { type: "agent_disconnected" });
262
273
  this.conn = null;
263
274
  }
264
275
  /** Send a prompt and collect the full text response without emitting events. */
@@ -323,6 +334,7 @@ export class AgentBridge extends EventEmitter {
323
334
  if (this.reloading)
324
335
  throw new Error("Already reloading");
325
336
  this.reloading = true;
337
+ const liveSessionIds = [...sessions.liveSessions];
326
338
  this.emit("event", { type: "agent_reloading" });
327
339
  blog.info("reloading agent...");
328
340
  try {
@@ -341,15 +353,24 @@ export class AgentBridge extends EventEmitter {
341
353
  }
342
354
  }
343
355
  // 2. Flush buffers to persist partial content
344
- for (const sessionId of sessions.liveSessions) {
356
+ for (const sessionId of liveSessionIds) {
345
357
  sessions.flushBuffers(sessionId);
346
358
  }
347
359
  // 3. Clean up SessionManager state
348
360
  sessions.pendingPermissions.clear();
349
- for (const id of sessions.activePrompts) {
361
+ sessions.state.clearPlans();
362
+ const busySessionIds = new Set([
363
+ ...sessions.activePrompts,
364
+ ...sessions.pendingPromptSubmissions.keys(),
365
+ ]);
366
+ for (const id of busySessionIds) {
350
367
  sessions.state.patch(id, { runtime: { busy: null } });
351
368
  }
369
+ for (const submissionId of sessions.pendingPromptSubmissions.values()) {
370
+ sessions.cancelledPromptSubmissions.add(submissionId);
371
+ }
352
372
  sessions.activePrompts.clear();
373
+ sessions.pendingPromptSubmissions.clear();
353
374
  // 4. Clean up bridge-side silent session state
354
375
  this.silentSessions.clear();
355
376
  this.silentBuffers.clear();
@@ -363,6 +384,13 @@ export class AgentBridge extends EventEmitter {
363
384
  sessions.cachedConfigOptions = [];
364
385
  // 6. Shutdown old process
365
386
  await this.shutdown();
387
+ // Cancellation is asynchronous: the old agent may emit final chunks
388
+ // before shutdown completes. Persist that tail and make the terminal
389
+ // stream state authoritative before starting the replacement process.
390
+ for (const sessionId of liveSessionIds) {
391
+ sessions.flushBuffers(sessionId);
392
+ }
393
+ sessions.state.clearStreaming();
366
394
  // 7. Start new process with retry (exponential backoff, max 3 attempts)
367
395
  let lastError;
368
396
  for (let i = 0; i < 3; i++) {
@@ -399,17 +427,28 @@ export class AgentBridge extends EventEmitter {
399
427
  }
400
428
  this.permissionResolvers.clear();
401
429
  this.permissionRequestSessions.clear();
402
- if (this.proc?.exitCode === null) {
403
- const proc = this.proc;
430
+ const proc = this.proc;
431
+ if (proc && !this.closedProcesses.has(proc)) {
404
432
  await new Promise((resolve) => {
405
- const timer = setTimeout(() => {
406
- proc.kill(process.platform === "win32" ? undefined : "SIGKILL");
433
+ let settled = false;
434
+ const finish = () => {
435
+ if (settled)
436
+ return;
437
+ settled = true;
438
+ this.closedProcesses.add(proc);
439
+ clearTimeout(killTimer);
440
+ clearTimeout(drainTimer);
441
+ proc.off("close", finish);
407
442
  resolve();
443
+ };
444
+ const killTimer = setTimeout(() => {
445
+ proc.kill(process.platform === "win32" ? undefined : "SIGKILL");
408
446
  }, 5000);
409
- proc.on("exit", () => {
410
- clearTimeout(timer);
411
- resolve();
412
- });
447
+ // `close` follows `exit` after stdio has drained. Keep a bounded
448
+ // fallback for pathological child-process implementations that never
449
+ // deliver close even after SIGKILL.
450
+ const drainTimer = setTimeout(finish, 6000);
451
+ proc.once("close", finish);
413
452
  proc.kill();
414
453
  });
415
454
  }
@@ -499,6 +538,16 @@ export class AgentBridge extends EventEmitter {
499
538
  configOptions: update
500
539
  .configOptions ?? [],
501
540
  };
541
+ case "available_commands_update":
542
+ return {
543
+ type: "available_commands_update",
544
+ sessionId,
545
+ commands: update.availableCommands.map((command) => ({
546
+ name: command.name,
547
+ description: command.description,
548
+ ...(command.input ? { input: { hint: command.input.hint } } : {}),
549
+ })),
550
+ };
502
551
  default:
503
552
  return null;
504
553
  }
@@ -3,6 +3,7 @@ import { log } from "./log.js";
3
3
  import { isAutopilotMode } from "./mode-bucket.js";
4
4
  const ailog = log.scope("attachment-interceptor");
5
5
  const plog = log.scope("push");
6
+ const clog = log.scope("cancel");
6
7
  function handleConnected(event, sessions, config) {
7
8
  event.cancelTimeout = config.cancelTimeout;
8
9
  event.recentPathsLimit = config.recentPathsLimit;
@@ -10,14 +11,8 @@ function handleConnected(event, sessions, config) {
10
11
  if (event.agent)
11
12
  sessions.agentInfo = event.agent;
12
13
  }
13
- function handleConfigLikeEvent(event, sessions, store) {
14
- if (event.configOptions.length)
15
- sessions.cachedConfigOptions = event.configOptions;
16
- for (const opt of event.configOptions) {
17
- if (typeof opt.currentValue === "string") {
18
- store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
19
- }
20
- }
14
+ function handleConfigLikeEvent(event, sessions) {
15
+ sessions.recordConfigOptions(event.sessionId, event.configOptions);
21
16
  }
22
17
  function handleMessageChunk(event, sessions) {
23
18
  sessions.flushThinkingBuffer(event.sessionId);
@@ -48,7 +43,13 @@ function handleToolCall(event, sessions, store) {
48
43
  function handlePlan(event, sessions, store) {
49
44
  sessions.flushBuffers(event.sessionId);
50
45
  sessions.state.patch(event.sessionId, {
51
- runtime: { streaming: { assistant: false, thinking: false } },
46
+ runtime: {
47
+ streaming: { assistant: false, thinking: false },
48
+ plan: event.entries.length > 0 &&
49
+ !event.entries.every((entry) => entry.status === "completed")
50
+ ? event.entries
51
+ : null,
52
+ },
52
53
  });
53
54
  store.saveEvent(event.sessionId, event.type, { entries: event.entries }, { from_ref: "agent" });
54
55
  }
@@ -144,18 +145,57 @@ function handlePermissionRequest(event, sessions, store, bridge, sseManager, con
144
145
  return false;
145
146
  }
146
147
  function handlePromptDone(event, sessions, store) {
147
- sessions.activePrompts.delete(event.sessionId);
148
- sessions.syncBusy(event.sessionId);
148
+ const cancelStatus = sessions.state.getState(event.sessionId).runtime.busy?.cancelStatus ?? null;
149
+ if (cancelStatus !== null) {
150
+ clog.info("agent completed after request", {
151
+ sessionId: event.sessionId.slice(0, 8),
152
+ requestedStatus: cancelStatus,
153
+ stopReason: event.stopReason,
154
+ });
155
+ }
156
+ // The tail this turn buffered must always land, even when the turn has
157
+ // already been superseded — it is the only copy of that text.
158
+ const isCurrent = sessions.isCurrentPrompt(event.sessionId, event.promptId);
159
+ if (isCurrent) {
160
+ sessions.activePrompts.delete(event.sessionId);
161
+ sessions.syncBusy(event.sessionId);
162
+ }
163
+ else {
164
+ clog.info("completion from a superseded turn", {
165
+ sessionId: event.sessionId.slice(0, 8),
166
+ promptId: event.promptId,
167
+ stopReason: event.stopReason,
168
+ });
169
+ }
149
170
  sessions.flushBuffers(event.sessionId);
150
171
  sessions.state.patch(event.sessionId, {
151
172
  runtime: { streaming: { assistant: false, thinking: false } },
152
173
  });
153
- store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason }, { from_ref: "agent" });
174
+ store.saveEvent(event.sessionId, event.type, {
175
+ stopReason: event.stopReason,
176
+ // Replay must be able to make the same judgement the live path does.
177
+ ...(event.promptId ? { promptId: event.promptId } : {}),
178
+ }, { from_ref: "agent" });
154
179
  }
155
180
  function handleError(event, sessions) {
156
181
  if (event.sessionId) {
157
- sessions.activePrompts.delete(event.sessionId);
158
- sessions.syncBusy(event.sessionId);
182
+ // Same attribution as a completion: a superseded turn failing late must
183
+ // not end the turn that replaced it. The buffered tail still flushes.
184
+ if (sessions.isCurrentPrompt(event.sessionId, event.promptId)) {
185
+ sessions.activePrompts.delete(event.sessionId);
186
+ sessions.syncBusy(event.sessionId);
187
+ }
188
+ else {
189
+ clog.info("failure from a superseded turn", {
190
+ sessionId: event.sessionId.slice(0, 8),
191
+ promptId: event.promptId,
192
+ message: event.message,
193
+ });
194
+ }
195
+ sessions.flushBuffers(event.sessionId);
196
+ sessions.state.patch(event.sessionId, {
197
+ runtime: { streaming: { assistant: false, thinking: false } },
198
+ });
159
199
  }
160
200
  }
161
201
  function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager) {
@@ -166,7 +206,7 @@ function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager)
166
206
  return false;
167
207
  case "session_created":
168
208
  case "config_option_update":
169
- handleConfigLikeEvent(event, sessions, store);
209
+ handleConfigLikeEvent(event, sessions);
170
210
  return false;
171
211
  case "message_chunk":
172
212
  handleMessageChunk(event, sessions);
@@ -213,6 +253,26 @@ function maybePushNotify(event, pushService) {
213
253
  });
214
254
  }
215
255
  export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService, _clientRegistry) {
256
+ if (event.type === "available_commands_update") {
257
+ const snapshot = sessions.updateAgentCommands(event.sessionId, event.commands);
258
+ if (sessions.restoringSessions.has(event.sessionId))
259
+ return;
260
+ sseManager.broadcast({ ...event, ...snapshot });
261
+ return;
262
+ }
263
+ if (event.type === "agent_reloading" || event.type === "agent_disconnected") {
264
+ for (const sessionId of sessions.liveSessions) {
265
+ sessions.flushBuffers(sessionId);
266
+ }
267
+ sessions.state.clearStreaming();
268
+ sessions.state.clearPlans();
269
+ for (const snapshot of sessions.clearAgentCommands()) {
270
+ sseManager.broadcast({
271
+ type: "available_commands_update",
272
+ ...snapshot,
273
+ });
274
+ }
275
+ }
216
276
  if ("sessionId" in event &&
217
277
  event.sessionId &&
218
278
  sessions.restoringSessions.has(event.sessionId))
@@ -0,0 +1,17 @@
1
+ export const HTTP_STATUS = Object.freeze({
2
+ OK: 200,
3
+ CREATED: 201,
4
+ ACCEPTED: 202,
5
+ NO_CONTENT: 204,
6
+ BAD_REQUEST: 400,
7
+ UNAUTHORIZED: 401,
8
+ FORBIDDEN: 403,
9
+ NOT_FOUND: 404,
10
+ CONFLICT: 409,
11
+ GONE: 410,
12
+ PAYLOAD_TOO_LARGE: 413,
13
+ UNPROCESSABLE_CONTENT: 422,
14
+ INTERNAL_SERVER_ERROR: 500,
15
+ NOT_IMPLEMENTED: 501,
16
+ SERVICE_UNAVAILABLE: 503,
17
+ });
@@ -6,13 +6,14 @@ const DAY_MS = 24 * 60 * 60 * 1000;
6
6
  * Returns the number of rows removed. `now` is injectable for tests.
7
7
  * ttlDays=0 means "keep forever" — returns 0 without touching the DB.
8
8
  */
9
- export function sweepOnce(store, ttlDays, now = Date.now()) {
9
+ export function sweepOnce(store, ttlDays, now = Date.now(), onPendingCountChange) {
10
10
  if (ttlDays <= 0)
11
11
  return 0;
12
12
  const threshold = now - ttlDays * DAY_MS;
13
13
  const removed = store.deleteOlderThan(threshold);
14
14
  if (removed > 0) {
15
15
  mlog.info("ttl sweep", { removed, ttl_days: ttlDays });
16
+ onPendingCountChange?.(store.countUnprocessed());
16
17
  }
17
18
  return removed;
18
19
  }
@@ -23,14 +24,14 @@ export function sweepOnce(store, ttlDays, now = Date.now()) {
23
24
  *
24
25
  * ttlDays=0 disables the scheduler entirely (handle.armed=false).
25
26
  */
26
- export function startMessageCleanup(store, ttlDays) {
27
- sweepOnce(store, ttlDays);
27
+ export function startMessageCleanup(store, ttlDays, onPendingCountChange) {
28
+ sweepOnce(store, ttlDays, Date.now(), onPendingCountChange);
28
29
  if (ttlDays <= 0) {
29
30
  return { armed: false, stop: () => { } };
30
31
  }
31
32
  const timer = setInterval(() => {
32
33
  try {
33
- sweepOnce(store, ttlDays);
34
+ sweepOnce(store, ttlDays, Date.now(), onPendingCountChange);
34
35
  }
35
36
  catch (err) {
36
37
  mlog.error("ttl sweep failed", { error: err });
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { join } from "node:path";
5
5
  import { log } from "./log.js";
6
+ import { HTTP_STATUS } from "./http-status.js";
6
7
  const slog = log.scope("push");
7
8
  const elog = log.scope("egress");
8
9
  const VAPID_FILE = "vapid.json";
@@ -267,7 +268,7 @@ export class PushService {
267
268
  else {
268
269
  fail++;
269
270
  const err = result.reason;
270
- if (err.statusCode === 410) {
271
+ if (err.statusCode === HTTP_STATUS.GONE) {
271
272
  fail410++;
272
273
  this.store.removeSubscription(endpoint);
273
274
  this.failureCounts.delete(endpoint);