@lelouchhe/webagent 0.7.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
+ 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">
@@ -16,9 +16,9 @@
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.3CCHZCG7.js">
21
+ <link rel="modulepreload" href="/s/_/chunk.CT5WBNGZ.js">
22
22
  <link rel="modulepreload" href="/s/_/chunk.UMQMOGWO.js">
23
23
  <link rel="modulepreload" href="/s/_/chunk.AJZBJBMO.js">
24
24
  </head>
@@ -49,6 +49,6 @@
49
49
  >
50
50
  </div>
51
51
  </footer>
52
- <script type="module" src="/s/_/viewer.T32M5AZZ.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
  });
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 {
@@ -324,6 +334,7 @@ export class AgentBridge extends EventEmitter {
324
334
  if (this.reloading)
325
335
  throw new Error("Already reloading");
326
336
  this.reloading = true;
337
+ const liveSessionIds = [...sessions.liveSessions];
327
338
  this.emit("event", { type: "agent_reloading" });
328
339
  blog.info("reloading agent...");
329
340
  try {
@@ -342,15 +353,24 @@ export class AgentBridge extends EventEmitter {
342
353
  }
343
354
  }
344
355
  // 2. Flush buffers to persist partial content
345
- for (const sessionId of sessions.liveSessions) {
356
+ for (const sessionId of liveSessionIds) {
346
357
  sessions.flushBuffers(sessionId);
347
358
  }
348
359
  // 3. Clean up SessionManager state
349
360
  sessions.pendingPermissions.clear();
350
- 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) {
351
367
  sessions.state.patch(id, { runtime: { busy: null } });
352
368
  }
369
+ for (const submissionId of sessions.pendingPromptSubmissions.values()) {
370
+ sessions.cancelledPromptSubmissions.add(submissionId);
371
+ }
353
372
  sessions.activePrompts.clear();
373
+ sessions.pendingPromptSubmissions.clear();
354
374
  // 4. Clean up bridge-side silent session state
355
375
  this.silentSessions.clear();
356
376
  this.silentBuffers.clear();
@@ -364,6 +384,13 @@ export class AgentBridge extends EventEmitter {
364
384
  sessions.cachedConfigOptions = [];
365
385
  // 6. Shutdown old process
366
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();
367
394
  // 7. Start new process with retry (exponential backoff, max 3 attempts)
368
395
  let lastError;
369
396
  for (let i = 0; i < 3; i++) {
@@ -400,17 +427,28 @@ export class AgentBridge extends EventEmitter {
400
427
  }
401
428
  this.permissionResolvers.clear();
402
429
  this.permissionRequestSessions.clear();
403
- if (this.proc?.exitCode === null) {
404
- const proc = this.proc;
430
+ const proc = this.proc;
431
+ if (proc && !this.closedProcesses.has(proc)) {
405
432
  await new Promise((resolve) => {
406
- const timer = setTimeout(() => {
407
- 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);
408
442
  resolve();
443
+ };
444
+ const killTimer = setTimeout(() => {
445
+ proc.kill(process.platform === "win32" ? undefined : "SIGKILL");
409
446
  }, 5000);
410
- proc.on("exit", () => {
411
- clearTimeout(timer);
412
- resolve();
413
- });
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);
414
452
  proc.kill();
415
453
  });
416
454
  }
@@ -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);
@@ -221,6 +261,11 @@ export function handleAgentEvent(event, sessions, store, bridge, config, sseMana
221
261
  return;
222
262
  }
223
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();
224
269
  for (const snapshot of sessions.clearAgentCommands()) {
225
270
  sseManager.broadcast({
226
271
  type: "available_commands_update",
@@ -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 });