@lelouchhe/webagent 0.3.0 → 0.5.1

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 (61) hide show
  1. package/README.md +59 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +105 -3
  4. package/dist/fonts/temml/Temml.woff2 +0 -0
  5. package/dist/index.html +64 -41
  6. package/dist/js/app.3OEVQXHK.js +4 -0
  7. package/dist/js/chunk.AJZBJBMO.js +1 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.IJM5DBCO.js +173 -0
  10. package/dist/js/chunk.VZXGXFNN.js +5 -0
  11. package/dist/js/login.PYIK52HN.js +1 -0
  12. package/dist/js/viewer.FCSSTUVY.js +1 -0
  13. package/dist/login.html +49 -0
  14. package/dist/share-viewer.00gubshk.css +114 -0
  15. package/dist/share-viewer.html +54 -0
  16. package/dist/styles.00xfh3e6.css +1848 -0
  17. package/dist/sw.js +79 -27
  18. package/dist/theme-init.js +6 -0
  19. package/lib/agent-detect.js +110 -0
  20. package/lib/atomic-write.js +50 -0
  21. package/lib/attachment-dispatch.js +86 -0
  22. package/lib/attachment-interceptor.js +130 -0
  23. package/lib/attachment-labels.js +139 -0
  24. package/lib/attachments.js +154 -0
  25. package/lib/auth-middleware.js +105 -0
  26. package/lib/auth-store.js +269 -0
  27. package/lib/auth.js +89 -0
  28. package/lib/bootstrap.js +70 -0
  29. package/lib/bridge-event-config.js +29 -0
  30. package/lib/bridge.js +244 -93
  31. package/lib/client-registry.js +149 -0
  32. package/lib/config.js +130 -9
  33. package/lib/daemon.js +175 -41
  34. package/lib/event-handler.js +209 -91
  35. package/lib/image-dimensions.js +64 -0
  36. package/lib/log-fmt.js +67 -0
  37. package/lib/log.js +83 -0
  38. package/lib/message-cleanup.js +48 -0
  39. package/lib/mode-bucket.js +62 -0
  40. package/lib/model-picker.js +17 -0
  41. package/lib/preflight.js +214 -0
  42. package/lib/push-service.js +297 -52
  43. package/lib/routes.js +1315 -145
  44. package/lib/server.js +149 -37
  45. package/lib/session-manager.js +164 -18
  46. package/lib/session-state.js +160 -0
  47. package/lib/sessions-anchor.js +28 -0
  48. package/lib/share/cleanup.js +45 -0
  49. package/lib/share/routes.js +972 -0
  50. package/lib/share/sanitize.js +179 -0
  51. package/lib/sse-manager.js +94 -8
  52. package/lib/sse-ticket.js +45 -0
  53. package/lib/startup-checks.js +95 -0
  54. package/lib/store.js +636 -30
  55. package/lib/title-service.js +26 -9
  56. package/lib/tokens.js +50 -0
  57. package/lib/types.js +23 -0
  58. package/package.json +39 -4
  59. package/dist/js/app.2562YGRO.js +0 -10
  60. package/dist/styles.008ve1hx.css +0 -669
  61. package/lib/shared/constants.js +0 -17
package/lib/server.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { loadConfig } from "./config.js";
6
+ import { setLogLevel, log } from "./log.js";
6
7
  import { AgentBridge } from "./bridge.js";
7
8
  import { Store } from "./store.js";
8
9
  import { SessionManager } from "./session-manager.js";
@@ -11,20 +12,41 @@ import { createRequestHandler } from "./routes.js";
11
12
  import { handleAgentEvent } from "./event-handler.js";
12
13
  import { PushService } from "./push-service.js";
13
14
  import { SseManager } from "./sse-manager.js";
15
+ import { TicketStore } from "./sse-ticket.js";
16
+ import { randomBytes } from "node:crypto";
17
+ import { ClientRegistry } from "./client-registry.js";
18
+ import { startMessageCleanup } from "./message-cleanup.js";
19
+ import { startSharePreviewCleanup, } from "./share/cleanup.js";
20
+ import { AuthStore } from "./auth-store.js";
21
+ import { join as pathJoin } from "node:path";
22
+ import { resolveSessionsAnchor } from "./sessions-anchor.js";
23
+ import { runStartupChecks } from "./startup-checks.js";
24
+ import { AttachmentDispatcher } from "./attachment-dispatch.js";
25
+ import { buildBridgeEventHandlerConfig as _buildBridgeEventHandlerConfig } from "./bridge-event-config.js";
26
+ import { createCounters as createAttachmentInterceptorCounters, } from "./attachment-interceptor.js";
14
27
  // Prefix all console output with ISO-ish timestamps (YYYY-MM-DD HH:MM:SS)
15
28
  for (const method of ["log", "error", "warn"]) {
16
29
  const orig = console[method].bind(console);
17
30
  console[method] = (...args) => {
18
- const ts = new Date().toLocaleString("sv-SE", { hour12: false }).replace(",", "");
31
+ const ts = new Date()
32
+ .toLocaleString("sv-SE", { hour12: false })
33
+ .replace(",", "");
19
34
  orig(ts, ...args);
20
35
  };
21
36
  }
22
37
  const config = loadConfig();
38
+ setLogLevel(config.debug.level);
39
+ // Unified startup gate: preflight (node, data_dir, agent, port) + auth
40
+ // bootstrap (mint or refuse). Skipped via WEBAGENT_STARTUP_CHECKED=1
41
+ // when a parent process (daemon supervisor) already ran the gate in
42
+ // the operator's foreground TTY before forking.
43
+ const preflight = await runStartupChecks(config);
23
44
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
24
45
  const PUBLIC_DIR = join(__dirname, "..", config.public_dir);
25
46
  const PKG_VERSION = (() => {
26
47
  try {
27
- return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version ?? "unknown";
48
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
49
+ return pkg.version ?? "unknown";
28
50
  }
29
51
  catch {
30
52
  return "unknown";
@@ -33,34 +55,95 @@ const PKG_VERSION = (() => {
33
55
  // --- Core dependencies ---
34
56
  const store = new Store(config.data_dir);
35
57
  console.log(`[store] using ${config.data_dir}/`);
58
+ // Pin <data_dir>/sessions realpath at boot so all later anchor checks
59
+ // (file:// URI construction, permission interceptor) compare against the
60
+ // same canonical path. Defends against macOS /var → /private/var.
61
+ const sessionsAnchor = resolveSessionsAnchor(config.data_dir);
62
+ const attachmentDispatcher = new AttachmentDispatcher(store, sessionsAnchor, {
63
+ warn: (msg) => {
64
+ console.warn(msg);
65
+ },
66
+ });
67
+ // Counters + once-per-process schemaDrift signal for the permission
68
+ // auto-approve interceptor (uploads-plan v2.6 §1.4 F7). Dumped hourly.
69
+ const attachmentInterceptorCounters = createAttachmentInterceptorCounters();
70
+ let lastSchemaDriftAt = 0;
71
+ const SCHEMA_DRIFT_THROTTLE_MS = 24 * 60 * 60 * 1000;
72
+ const ATTACHMENT_INTERCEPTOR_DUMP_MS = 60 * 60 * 1000;
73
+ setInterval(() => {
74
+ log
75
+ .scope("attachment-interceptor")
76
+ .info("counters", { ...attachmentInterceptorCounters });
77
+ }, ATTACHMENT_INTERCEPTOR_DUMP_MS).unref();
36
78
  const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
37
- const titleService = new TitleService(store, sessions, config.default_cwd);
38
- const pushService = new PushService(store, config.data_dir, config.push.vapid_subject);
39
- console.log(`[push] VAPID public key ready`);
79
+ const titleService = new TitleService(store, sessions, config.default_cwd, config.title.models);
40
80
  const sseManager = new SseManager();
41
- sseManager.onRemove((clientId) => pushService.removeClient(clientId));
81
+ const clientRegistry = new ClientRegistry();
82
+ const pushService = new PushService(store, config.data_dir, config.push.vapid_subject, {
83
+ globalVisibilitySuppression: config.push.global_visibility_suppression,
84
+ clientRegistry,
85
+ });
86
+ console.log(`[push] VAPID public key ready`);
87
+ sseManager.onRemove((clientId) => {
88
+ pushService.removeClient(clientId);
89
+ clientRegistry.remove(clientId);
90
+ });
42
91
  sseManager.startHeartbeat();
92
+ const authStore = new AuthStore(pathJoin(config.data_dir, "auth.json"));
93
+ const ticketStore = new TicketStore();
94
+ // In-memory image signing secret; regenerated on every restart so previously
95
+ // leaked URLs become invalid the moment the server is bounced.
96
+ const attachmentSecret = randomBytes(32);
97
+ // SSE heartbeat re-checks token revocation; revoked → connection closed
98
+ // within one heartbeat interval (≤15s).
99
+ sseManager.setRevocationCheck((tokenName) => !authStore.hasTokenName(tokenName));
100
+ sseManager.setAttachmentSecret(attachmentSecret);
101
+ sseManager.setLabelMapProvider((sessionId) => sessions.getLabelMap(sessionId));
102
+ // Broadcast runtime state patches to all SSE clients interested in the session.
103
+ sessions.state.onPatch((event) => {
104
+ sseManager.broadcast(event);
105
+ });
43
106
  let bridge = null;
107
+ let messageCleanup = null;
108
+ let sharePreviewCleanup = null;
44
109
  // --- HTTP server ---
45
- const server = createServer(createRequestHandler({
46
- store,
47
- sessions,
48
- sseManager,
49
- titleService,
50
- getBridge: () => bridge,
51
- publicDir: PUBLIC_DIR,
52
- dataDir: config.data_dir,
53
- limits: config.limits,
54
- pushService,
55
- serverVersion: PKG_VERSION,
56
- }));
57
- async function initBridge() {
58
- const b = new AgentBridge(config.agent_cmd);
110
+ const server = createServer((req, res) => {
111
+ void createRequestHandler({
112
+ store,
113
+ sessions,
114
+ sseManager,
115
+ clientRegistry,
116
+ titleService,
117
+ getBridge: () => bridge,
118
+ publicDir: PUBLIC_DIR,
119
+ dataDir: config.data_dir,
120
+ limits: config.limits,
121
+ pushService,
122
+ serverVersion: PKG_VERSION,
123
+ debugLevel: config.debug.level,
124
+ authStore,
125
+ ticketStore,
126
+ attachmentSecret,
127
+ shareConfig: config.share,
128
+ })(req, res);
129
+ });
130
+ async function initBridge(agentCmd) {
131
+ const b = new AgentBridge(agentCmd);
132
+ b.setAttachmentDispatcher(attachmentDispatcher);
133
+ const eventHandlerConfig = _buildBridgeEventHandlerConfig({
134
+ cancelTimeout: config.limits.cancel_timeout,
135
+ recentPathsLimit: config.limits.recent_paths,
136
+ attachmentInterceptorCounters,
137
+ shouldLogSchemaDrift: () => {
138
+ const now = Date.now();
139
+ if (now - lastSchemaDriftAt < SCHEMA_DRIFT_THROTTLE_MS)
140
+ return false;
141
+ lastSchemaDriftAt = now;
142
+ return true;
143
+ },
144
+ });
59
145
  b.on("event", (event) => {
60
- handleAgentEvent(event, sessions, store, b, {
61
- cancelTimeout: config.limits.cancel_timeout,
62
- recentPathsLimit: config.limits.recent_paths,
63
- }, sseManager, pushService);
146
+ handleAgentEvent(event, sessions, store, b, eventHandlerConfig, sseManager, pushService, clientRegistry);
64
147
  });
65
148
  await b.start();
66
149
  bridge = b;
@@ -70,24 +153,53 @@ async function initBridge() {
70
153
  async function shutdown() {
71
154
  console.log("\n[server] shutting down...");
72
155
  sseManager.stopHeartbeat();
156
+ messageCleanup?.stop();
157
+ sharePreviewCleanup?.stop();
73
158
  sessions.killAllBashProcs();
74
159
  await bridge?.shutdown();
160
+ await authStore.close();
75
161
  store.close();
76
162
  server.close();
77
163
  process.exit(0);
78
164
  }
79
- process.on("SIGINT", shutdown);
80
- process.on("SIGTERM", shutdown);
165
+ process.on("SIGINT", () => {
166
+ void shutdown();
167
+ });
168
+ process.on("SIGTERM", () => {
169
+ void shutdown();
170
+ });
171
+ // SIGHUP: reload auth.json without restarting (e.g. after CLI revoked a token)
172
+ process.on("SIGHUP", () => {
173
+ authStore.reload().then(() => {
174
+ console.log("[auth] reloaded auth.json");
175
+ }, (err) => {
176
+ console.error("[auth] reload failed:", err);
177
+ });
178
+ });
81
179
  // --- Start ---
82
- server.listen(config.port, "0.0.0.0", async () => {
83
- console.log(`[server] listening on http://localhost:${config.port}`);
84
- console.log(`[bridge] starting: ${config.agent_cmd}...`);
85
- try {
86
- await initBridge();
87
- console.log(`[bridge] ready`);
88
- sessions.hydrate();
89
- }
90
- catch (err) {
91
- console.error(`[bridge] failed to start:`, err);
92
- }
180
+ server.listen(config.port, config.host, () => {
181
+ void (async () => {
182
+ // The auth gate already ran in runStartupChecks (above) either in
183
+ // this process or in a parent that handed off via WEBAGENT_STARTUP_
184
+ // CHECKED. Just open the AuthStore handle the rest of the server
185
+ // will use. If the gate ran, auth.json exists and has ≥ 1 token.
186
+ await authStore.load();
187
+ console.log(`[server] listening on http://localhost:${config.port}`);
188
+ messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days);
189
+ if (config.share.enabled) {
190
+ sharePreviewCleanup = startSharePreviewCleanup(store);
191
+ console.log(`[share] preview gc armed (24h interval)`);
192
+ }
193
+ // agent_cmd resolved by preflight (handles the "auto" sentinel).
194
+ const agentCmd = preflight.agentCmd;
195
+ console.log(`[bridge] starting: ${agentCmd}...`);
196
+ try {
197
+ await initBridge(agentCmd);
198
+ console.log(`[bridge] ready`);
199
+ sessions.hydrate();
200
+ }
201
+ catch (err) {
202
+ console.error(`[bridge] failed to start:`, err);
203
+ }
204
+ })();
93
205
  });
@@ -2,6 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { SessionStateManager } from "./session-state.js";
6
+ import { buildLabelMap } from "./attachment-labels.js";
7
+ import { log } from "./log.js";
8
+ const slog = log.scope("session");
5
9
  const IS_WIN = process.platform === "win32";
6
10
  export function interruptBashProc(proc) {
7
11
  if (!proc)
@@ -23,7 +27,7 @@ export function interruptBashProc(proc) {
23
27
  proc.kill("SIGINT");
24
28
  }
25
29
  /** Known config option IDs that we persist per-session. */
26
- const PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
30
+ const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
27
31
  /** Minimum age (seconds) before an empty session is eligible for cleanup. */
28
32
  const EMPTY_SESSION_MIN_AGE_S = 60;
29
33
  /**
@@ -40,8 +44,17 @@ export class SessionManager {
40
44
  runningBashProcs = new Map();
41
45
  /** Pending permission requests keyed by requestId. */
42
46
  pendingPermissions = new Map();
47
+ /** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
48
+ state = new SessionStateManager();
43
49
  /** Deduplicates concurrent resume calls for the same session. */
44
50
  pendingResumes = new Map();
51
+ /**
52
+ * Per-session attachment label map (CLAUDE.md "Attachment label
53
+ * egress rewrite"). Built lazily from the `attachments` table on
54
+ * first read; invalidated on attachment INSERT and session
55
+ * DELETE. Lookup is cheap (Map.get); rebuild is one SQLite query.
56
+ */
57
+ attachmentLabelCache = new Map();
45
58
  cachedConfigOptions = [];
46
59
  agentInfo = null;
47
60
  store;
@@ -75,11 +88,11 @@ export class SessionManager {
75
88
  for (const id of cleaned)
76
89
  this.liveSessions.delete(id);
77
90
  if (cleaned.length > 0)
78
- console.log(`[session] cleaned ${cleaned.length} empty session(s)`);
91
+ slog.info("cleaned empty session(s)", { count: cleaned.length });
79
92
  const sourceSession = inheritFromSessionId
80
93
  ? this.store.getSession(inheritFromSessionId)
81
94
  : null;
82
- const sessionId = await bridge.newSession(sessionCwd);
95
+ const { sessionId } = await bridge.newSession(sessionCwd);
83
96
  this.liveSessions.add(sessionId);
84
97
  this.store.createSession(sessionId, sessionCwd, source);
85
98
  // Inherit config options from source session
@@ -120,35 +133,77 @@ export class SessionManager {
120
133
  cwd: session.cwd,
121
134
  title: session.title,
122
135
  configOptions,
123
- busyKind: this.getBusyKind(sessionId) ?? undefined,
124
136
  };
125
137
  }
126
138
  // Restore via ACP
127
139
  this.restoringSessions.add(sessionId);
128
140
  try {
129
- const restored = await bridge.loadSession(sessionId, session.cwd);
141
+ await bridge.loadSession(sessionId, session.cwd);
130
142
  this.liveSessions.add(sessionId);
131
143
  if (session.title)
132
144
  this.sessionHasTitle.add(sessionId);
133
- const configOptions = this.applyStoredConfig(restored.configOptions, session);
134
- console.log(`[session] restored: ${sessionId.slice(0, 8)}…`);
145
+ // Piggyback a cache-warming setConfigOption on the user's own resume
146
+ // when the global cache is empty (typical after bridge.restart). Uses
147
+ // the session's own stored value — idempotent, no side effect. Failure
148
+ // is swallowed: the resume still succeeds and the frontend falls back
149
+ // to snapshot-based mode/model display (see public/js/state.ts).
150
+ await this.tryWarmCache(bridge, sessionId, session);
151
+ const configOptions = this.applyStoredConfig(this.cachedConfigOptions, session);
152
+ slog.info("restored", { sessionId: sessionId.slice(0, 8) + "…" });
135
153
  return {
136
154
  type: "session_created",
137
155
  sessionId,
138
156
  cwd: session.cwd,
139
157
  title: session.title,
140
158
  configOptions,
141
- busyKind: this.getBusyKind(sessionId) ?? undefined,
142
159
  };
143
160
  }
144
161
  catch (err) {
145
- console.error(`[session] restore failed:`, err);
162
+ slog.error("restore failed", { error: err });
146
163
  throw err;
147
164
  }
148
165
  finally {
149
166
  this.restoringSessions.delete(sessionId);
150
167
  }
151
168
  }
169
+ /**
170
+ * When cachedConfigOptions is empty, use the session's own stored config
171
+ * value to trigger a setConfigOption. The agent's response carries the
172
+ * full ConfigOption[] schema (options lists + in-memory currentValues),
173
+ * which we cache. Writes **only** the global cache, never the session's
174
+ * DB row — setConfigOption's currentValue for unrelated keys is the
175
+ * agent's in-memory default, not the user's preference.
176
+ *
177
+ * Key priority mode > reasoning_effort > model:
178
+ * - mode is a small stable enum, rewriting current value is idempotent.
179
+ * - model has the highest schema-drift risk (agent upgrades drop values).
180
+ */
181
+ async tryWarmCache(bridge, sessionId, session) {
182
+ if (this.cachedConfigOptions.length > 0)
183
+ return;
184
+ const pick = session.mode
185
+ ? { id: "mode", value: session.mode }
186
+ : session.reasoning_effort
187
+ ? { id: "reasoning_effort", value: session.reasoning_effort }
188
+ : session.model
189
+ ? { id: "model", value: session.model }
190
+ : null;
191
+ if (!pick)
192
+ return;
193
+ try {
194
+ const opts = await bridge.setConfigOption(sessionId, pick.id, pick.value);
195
+ if (opts.length > 0) {
196
+ this.cachedConfigOptions = opts;
197
+ slog.info("warmed cache on resume", { options: opts.length });
198
+ }
199
+ }
200
+ catch (err) {
201
+ slog.warn("cache warming failed", {
202
+ sessionId: sessionId.slice(0, 8) + "…",
203
+ error: err,
204
+ });
205
+ }
206
+ }
152
207
  /**
153
208
  * Ensure a session is resumed (live in ACP). Deduplicates concurrent calls.
154
209
  * Unlike resumeSession(), this is fire-and-forget safe — callers that only
@@ -186,22 +241,52 @@ export class SessionManager {
186
241
  return opt;
187
242
  });
188
243
  }
244
+ /**
245
+ * Lazy-build and return the attachment label map for a session.
246
+ * Used by both egress chokepoints (SSE broadcast + replay helper)
247
+ * to translate uuid paths into `<name> [#<id4>]` labels.
248
+ *
249
+ * Cached; call `invalidateLabelCache(sid)` after any attachments
250
+ * INSERT/DELETE to force rebuild. Restart-safe: empty on cold
251
+ * start, rebuilt on first egress per session.
252
+ */
253
+ getLabelMap(sessionId) {
254
+ const hit = this.attachmentLabelCache.get(sessionId);
255
+ if (hit)
256
+ return hit;
257
+ const map = buildLabelMap(this.store.listAttachmentLabels(sessionId));
258
+ this.attachmentLabelCache.set(sessionId, map);
259
+ return map;
260
+ }
261
+ /** Invalidate label cache for a session (call after attachment write). */
262
+ invalidateLabelCache(sessionId) {
263
+ this.attachmentLabelCache.delete(sessionId);
264
+ }
189
265
  /** Delete a session from store and clean up all state (including images). */
190
266
  deleteSession(sessionId) {
191
- this.store.deleteSession(sessionId);
267
+ const mode = this.store.deleteSession(sessionId);
192
268
  this.liveSessions.delete(sessionId);
193
269
  this.sessionHasTitle.delete(sessionId);
194
270
  this.assistantBuffers.delete(sessionId);
195
271
  this.thinkingBuffers.delete(sessionId);
196
272
  this.activePrompts.delete(sessionId);
197
273
  this.runningBashProcs.delete(sessionId);
274
+ this.attachmentLabelCache.delete(sessionId);
198
275
  // Clean pending permissions for this session
199
276
  for (const [reqId, perm] of this.pendingPermissions) {
200
277
  if (perm.sessionId === sessionId)
201
278
  this.pendingPermissions.delete(reqId);
202
279
  }
203
- // Remove uploaded images for this session
204
- rm(join(this.dataDir, "images", sessionId), { recursive: true, force: true }).catch(() => { });
280
+ this.state.delete(sessionId);
281
+ if (mode === "hard") {
282
+ // Tombstoned sessions keep their attachments alive for the share viewer
283
+ // (shared files still resolve via /s/:token/attachments/...). The reap
284
+ // path in share/routes.ts removes them once the last share is gone.
285
+ rm(join(this.dataDir, "sessions", sessionId), {
286
+ recursive: true,
287
+ force: true,
288
+ }).catch(() => { });
289
+ }
205
290
  }
206
291
  /** Flush assistant/thinking buffers to store. */
207
292
  flushBuffers(sessionId) {
@@ -212,7 +297,7 @@ export class SessionManager {
212
297
  flushAssistantBuffer(sessionId) {
213
298
  const assistant = this.assistantBuffers.get(sessionId);
214
299
  if (assistant) {
215
- this.store.saveEvent(sessionId, "assistant_message", { text: assistant });
300
+ this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
216
301
  this.assistantBuffers.delete(sessionId);
217
302
  }
218
303
  }
@@ -220,7 +305,7 @@ export class SessionManager {
220
305
  flushThinkingBuffer(sessionId) {
221
306
  const thinking = this.thinkingBuffers.get(sessionId);
222
307
  if (thinking) {
223
- this.store.saveEvent(sessionId, "thinking", { text: thinking });
308
+ this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
224
309
  this.thinkingBuffers.delete(sessionId);
225
310
  }
226
311
  }
@@ -245,6 +330,37 @@ export class SessionManager {
245
330
  return "agent";
246
331
  return null;
247
332
  }
333
+ /**
334
+ * Recompute busy from active prompts/bash procs and patch the state manager.
335
+ * Call this immediately after mutating activePrompts / runningBashProcs so
336
+ * the frontend snapshot stays in sync via `state_patch` broadcast.
337
+ *
338
+ * `promptId` attaches to an agent busy transition (ignored otherwise). If
339
+ * omitted when staying agent-busy, the existing promptId is preserved.
340
+ */
341
+ syncBusy(sessionId, promptId) {
342
+ const kind = this.getBusyKind(sessionId);
343
+ const current = this.state.getState(sessionId).runtime.busy;
344
+ if (kind === null) {
345
+ if (current !== null)
346
+ this.state.patch(sessionId, { runtime: { busy: null } });
347
+ // Also clear any pending cancel safety net now that we are idle.
348
+ this.state.clearCancelSafety(sessionId);
349
+ return;
350
+ }
351
+ const nextPromptId = kind === "agent" ? (promptId ?? current?.promptId ?? null) : null;
352
+ if (current?.kind === kind && current.promptId === nextPromptId)
353
+ return;
354
+ this.state.patch(sessionId, {
355
+ runtime: {
356
+ busy: {
357
+ kind: kind,
358
+ since: current?.kind === kind ? current.since : new Date().toISOString(),
359
+ promptId: nextPromptId,
360
+ },
361
+ },
362
+ });
363
+ }
248
364
  /**
249
365
  * If the session's last turn was interrupted (user_message without prompt_done),
250
366
  * auto-retry by prompting the agent to continue. Returns true if retrying.
@@ -254,18 +370,48 @@ export class SessionManager {
254
370
  return false;
255
371
  if (!this.store.hasInterruptedTurn(sessionId))
256
372
  return false;
257
- console.log(`[session] auto-retrying interrupted turn for ${sessionId.slice(0, 8)}…`);
373
+ slog.info("auto-retrying interrupted turn", {
374
+ sessionId: sessionId.slice(0, 8) + "…",
375
+ });
258
376
  this.activePrompts.add(sessionId);
259
- bridge.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.").catch((err) => {
260
- console.error(`[session] auto-retry failed for ${sessionId.slice(0, 8)}…:`, err);
377
+ this.syncBusy(sessionId);
378
+ bridge
379
+ .prompt(sessionId, "Continue your previous response — it was interrupted mid-way.")
380
+ .catch((err) => {
381
+ slog.error("auto-retry failed", {
382
+ sessionId: sessionId.slice(0, 8) + "…",
383
+ error: err,
384
+ });
261
385
  this.activePrompts.delete(sessionId);
386
+ this.syncBusy(sessionId);
262
387
  });
263
388
  return true;
264
389
  }
265
390
  /** Get pending permission requests for a session (or all sessions if no id). */
266
391
  getPendingPermissions(sessionId) {
267
392
  const perms = [...this.pendingPermissions.values()];
268
- return sessionId ? perms.filter(p => p.sessionId === sessionId) : perms;
393
+ return sessionId ? perms.filter((p) => p.sessionId === sessionId) : perms;
394
+ }
395
+ /**
396
+ * Re-derive runtime.pendingPermissions from the Map and push via state_patch.
397
+ * Call this after every mutation of `pendingPermissions` so the frontend
398
+ * snapshot stays authoritative.
399
+ */
400
+ syncPendingPermissions(sessionId) {
401
+ const forSession = [...this.pendingPermissions.values()]
402
+ .filter((p) => p.sessionId === sessionId)
403
+ .map((p) => ({
404
+ requestId: p.requestId,
405
+ toolName: "",
406
+ title: p.title,
407
+ options: p.options.map((o) => ({
408
+ optionId: o.optionId,
409
+ label: o.label,
410
+ })),
411
+ }));
412
+ this.state.patch(sessionId, {
413
+ runtime: { pendingPermissions: forSession },
414
+ });
269
415
  }
270
416
  /** Kill all running bash processes (for shutdown). */
271
417
  killAllBashProcs() {