@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.
package/lib/server.js CHANGED
@@ -104,7 +104,9 @@ sessions.state.onPatch((event) => {
104
104
  sseManager.broadcast(event);
105
105
  });
106
106
  let bridge = null;
107
- let messageCleanup = null;
107
+ const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
108
+ sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
109
+ });
108
110
  let sharePreviewCleanup = null;
109
111
  // --- HTTP server ---
110
112
  const server = createServer((req, res) => {
@@ -153,7 +155,7 @@ async function initBridge(agentCmd) {
153
155
  async function shutdown() {
154
156
  console.log("\n[server] shutting down...");
155
157
  sseManager.stopHeartbeat();
156
- messageCleanup?.stop();
158
+ messageCleanup.stop();
157
159
  sharePreviewCleanup?.stop();
158
160
  sessions.killAllBashProcs();
159
161
  await bridge?.shutdown();
@@ -185,7 +187,6 @@ server.listen(config.port, config.host, () => {
185
187
  // will use. If the gate ran, auth.json exists and has ≥ 1 token.
186
188
  await authStore.load();
187
189
  console.log(`[server] listening on http://localhost:${config.port}`);
188
- messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days);
189
190
  if (config.share.enabled) {
190
191
  sharePreviewCleanup = startSharePreviewCleanup(store);
191
192
  console.log(`[share] preview gc armed (24h interval)`);
@@ -1,13 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { rm } from "node:fs/promises";
3
4
  import { stat } from "node:fs/promises";
4
5
  import { join } from "node:path";
6
+ import { MessageNotFoundError } from "./store.js";
5
7
  import { SessionStateManager } from "./session-state.js";
6
8
  import { buildLabelMap } from "./attachment-labels.js";
7
9
  import { log } from "./log.js";
8
10
  const slog = log.scope("session");
9
11
  const IS_WIN = process.platform === "win32";
10
- export function interruptBashProc(proc) {
12
+ export function interruptBashProc(proc, force = false) {
11
13
  if (!proc)
12
14
  return;
13
15
  if (IS_WIN && typeof proc.pid === "number") {
@@ -17,19 +19,23 @@ export function interruptBashProc(proc) {
17
19
  }
18
20
  if (typeof proc.pid === "number") {
19
21
  try {
20
- process.kill(-proc.pid, "SIGINT");
22
+ process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
21
23
  return;
22
24
  }
23
25
  catch {
24
26
  // Fall through to direct child kill when the process is not a group leader.
25
27
  }
26
28
  }
27
- proc.kill("SIGINT");
29
+ proc.kill(force ? "SIGKILL" : "SIGINT");
28
30
  }
29
- /** Known config option IDs that we persist per-session. */
30
- const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
31
31
  /** Minimum age (seconds) before an empty session is eligible for cleanup. */
32
32
  const EMPTY_SESSION_MIN_AGE_S = 60;
33
+ export class InvalidSessionDirectoryError extends Error {
34
+ constructor(cwd) {
35
+ super(`Directory does not exist: ${cwd}`);
36
+ this.name = "InvalidSessionDirectoryError";
37
+ }
38
+ }
33
39
  /**
34
40
  * Centralizes all session-related state that was previously scattered
35
41
  * across module-level variables in server.ts.
@@ -41,13 +47,20 @@ export class SessionManager {
41
47
  assistantBuffers = new Map();
42
48
  thinkingBuffers = new Map();
43
49
  activePrompts = new Set();
50
+ pendingPromptSubmissions = new Map();
51
+ cancelledPromptSubmissions = new Set();
44
52
  runningBashProcs = new Map();
53
+ interruptedBashProcs = new WeakSet();
45
54
  /** Pending permission requests keyed by requestId. */
46
55
  pendingPermissions = new Map();
47
56
  /** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
48
57
  state = new SessionStateManager();
49
58
  /** Deduplicates concurrent resume calls for the same session. */
50
59
  pendingResumes = new Map();
60
+ nextPromptNumber = 0;
61
+ nextPromptSubmissionNumber = 0;
62
+ /** Deduplicates concurrent attempts to materialize one inbox message. */
63
+ pendingMessageConsumes = new Map();
51
64
  /**
52
65
  * Per-session attachment label map (CLAUDE.md "Attachment label
53
66
  * egress rewrite"). Built lazily from the `attachments` table on
@@ -55,6 +68,8 @@ export class SessionManager {
55
68
  * DELETE. Lookup is cheap (Map.get); rebuild is one SQLite query.
56
69
  */
57
70
  attachmentLabelCache = new Map();
71
+ agentCommandSnapshots = new Map();
72
+ agentCommandEpoch = randomUUID();
58
73
  cachedConfigOptions = [];
59
74
  agentInfo = null;
60
75
  store;
@@ -73,7 +88,7 @@ export class SessionManager {
73
88
  }
74
89
  }
75
90
  /** Create a new session in both bridge and store, inheriting the source session's config. */
76
- async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
91
+ async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
77
92
  const sessionCwd = cwd ?? this.defaultCwd;
78
93
  try {
79
94
  const info = await stat(sessionCwd);
@@ -81,20 +96,33 @@ export class SessionManager {
81
96
  throw new Error("not a directory");
82
97
  }
83
98
  catch {
84
- throw new Error(`Directory does not exist: ${sessionCwd}`);
99
+ throw new InvalidSessionDirectoryError(sessionCwd);
85
100
  }
86
101
  // Clean up empty sessions (no events) older than the threshold
87
102
  const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
88
- for (const id of cleaned)
103
+ for (const id of cleaned) {
89
104
  this.liveSessions.delete(id);
105
+ this.agentCommandSnapshots.delete(id);
106
+ }
90
107
  if (cleaned.length > 0)
91
108
  slog.info("cleaned empty session(s)", { count: cleaned.length });
92
109
  const sourceSession = inheritFromSessionId
93
110
  ? this.store.getSession(inheritFromSessionId)
94
111
  : null;
95
- const { sessionId } = await bridge.newSession(sessionCwd);
112
+ const { sessionId, configOptions: createdConfigOptions } = await bridge.newSession(sessionCwd, { silent: opts?.silent });
113
+ let configOptions = createdConfigOptions;
114
+ try {
115
+ this.store.createSession(sessionId, sessionCwd, source);
116
+ }
117
+ catch (err) {
118
+ slog.warn("ACP session created but local persistence failed", {
119
+ sessionId,
120
+ error: err,
121
+ });
122
+ throw err;
123
+ }
96
124
  this.liveSessions.add(sessionId);
97
- this.store.createSession(sessionId, sessionCwd, source);
125
+ this.recordConfigOptions(sessionId, createdConfigOptions);
98
126
  // Inherit config options from source session
99
127
  if (sourceSession) {
100
128
  const inherited = [
@@ -105,8 +133,14 @@ export class SessionManager {
105
133
  if (!value)
106
134
  continue;
107
135
  try {
108
- await bridge.setConfigOption(sessionId, configId, value);
109
- this.store.updateSessionConfig(sessionId, configId, value);
136
+ const updatedConfigOptions = await bridge.setConfigOption(sessionId, configId, value);
137
+ if (updatedConfigOptions.length > 0) {
138
+ configOptions = updatedConfigOptions;
139
+ this.recordConfigOptions(sessionId, updatedConfigOptions);
140
+ }
141
+ else {
142
+ this.store.updateSessionConfig(sessionId, configId, value);
143
+ }
110
144
  }
111
145
  catch {
112
146
  // Option may no longer be available; ignore
@@ -116,9 +150,73 @@ export class SessionManager {
116
150
  const session = this.store.getSession(sessionId);
117
151
  return {
118
152
  sessionId,
119
- configOptions: session ? this.buildConfigOptions(session) : [],
153
+ configOptions: session
154
+ ? this.applyStoredConfig(configOptions, session)
155
+ : [],
120
156
  };
121
157
  }
158
+ /** Cache the ACP config schema and persist this session's current values. */
159
+ recordConfigOptions(sessionId, configOptions) {
160
+ if (configOptions.length === 0)
161
+ return;
162
+ this.cachedConfigOptions = configOptions;
163
+ for (const option of configOptions) {
164
+ if (typeof option.currentValue === "string") {
165
+ this.store.updateSessionConfig(sessionId, option.id, option.currentValue);
166
+ }
167
+ }
168
+ }
169
+ /**
170
+ * Materialize a pending inbox message as a real ACP-backed session.
171
+ * Returns null when the message is neither pending nor previously consumed.
172
+ */
173
+ consumeMessage(bridge, messageId, inheritFromSessionId) {
174
+ const pending = this.pendingMessageConsumes.get(messageId);
175
+ if (pending)
176
+ return pending;
177
+ const existing = this.store.findConsumedMessageSession(messageId);
178
+ if (existing) {
179
+ return Promise.resolve({
180
+ sessionId: existing,
181
+ alreadyConsumed: true,
182
+ });
183
+ }
184
+ const operation = this.consumePendingMessage(bridge, messageId, inheritFromSessionId).finally(() => {
185
+ if (this.pendingMessageConsumes.get(messageId) === operation) {
186
+ this.pendingMessageConsumes.delete(messageId);
187
+ }
188
+ });
189
+ this.pendingMessageConsumes.set(messageId, operation);
190
+ return operation;
191
+ }
192
+ async consumePendingMessage(bridge, messageId, inheritFromSessionId) {
193
+ const message = this.store.getMessage(messageId);
194
+ if (!message)
195
+ return null;
196
+ const { sessionId } = await this.createSession(bridge, message.cwd ?? undefined, inheritFromSessionId, "message", { silent: true });
197
+ try {
198
+ const result = this.store.consumeMessageTx(messageId, sessionId);
199
+ if (result.alreadyConsumed) {
200
+ this.deleteSession(sessionId);
201
+ }
202
+ return result;
203
+ }
204
+ catch (err) {
205
+ this.deleteSession(sessionId);
206
+ if (err instanceof MessageNotFoundError) {
207
+ const consumedSessionId = this.store.findConsumedMessageSession(messageId);
208
+ return consumedSessionId
209
+ ? { sessionId: consumedSessionId, alreadyConsumed: true }
210
+ : null;
211
+ }
212
+ slog.warn("message consume left an unreachable ACP session", {
213
+ messageId,
214
+ sessionId,
215
+ error: err,
216
+ });
217
+ throw err;
218
+ }
219
+ }
122
220
  /** Resume a session — returns event to send to the requesting client. */
123
221
  async resumeSession(bridge, sessionId) {
124
222
  const session = this.store.getSession(sessionId);
@@ -228,7 +326,7 @@ export class SessionManager {
228
326
  /** Override currentValue in configOptions with stored session values. */
229
327
  applyStoredConfig(configOptions, session) {
230
328
  if (!configOptions.length)
231
- return this.cachedConfigOptions;
329
+ return configOptions;
232
330
  const stored = {
233
331
  model: session.model,
234
332
  mode: session.mode,
@@ -262,6 +360,42 @@ export class SessionManager {
262
360
  invalidateLabelCache(sessionId) {
263
361
  this.attachmentLabelCache.delete(sessionId);
264
362
  }
363
+ updateAgentCommands(sessionId, commands) {
364
+ const current = this.agentCommandSnapshots.get(sessionId);
365
+ const snapshot = {
366
+ epoch: this.agentCommandEpoch,
367
+ revision: (current?.revision ?? 0) + 1,
368
+ commands: commands.map((command) => ({
369
+ ...command,
370
+ ...(command.input ? { input: { ...command.input } } : {}),
371
+ })),
372
+ };
373
+ this.agentCommandSnapshots.set(sessionId, snapshot);
374
+ return snapshot;
375
+ }
376
+ getAgentCommands(sessionId) {
377
+ return (this.agentCommandSnapshots.get(sessionId) ?? {
378
+ epoch: this.agentCommandEpoch,
379
+ revision: 0,
380
+ commands: [],
381
+ });
382
+ }
383
+ clearAgentCommands() {
384
+ const cleared = [];
385
+ for (const [sessionId, current] of this.agentCommandSnapshots) {
386
+ const snapshot = {
387
+ epoch: this.agentCommandEpoch,
388
+ revision: current.revision + 1,
389
+ commands: [],
390
+ };
391
+ this.agentCommandSnapshots.set(sessionId, snapshot);
392
+ cleared.push({
393
+ sessionId,
394
+ ...snapshot,
395
+ });
396
+ }
397
+ return cleared;
398
+ }
265
399
  /** Delete a session from store and clean up all state (including images). */
266
400
  deleteSession(sessionId) {
267
401
  const mode = this.store.deleteSession(sessionId);
@@ -270,8 +404,14 @@ export class SessionManager {
270
404
  this.assistantBuffers.delete(sessionId);
271
405
  this.thinkingBuffers.delete(sessionId);
272
406
  this.activePrompts.delete(sessionId);
407
+ const pendingSubmission = this.pendingPromptSubmissions.get(sessionId);
408
+ this.pendingPromptSubmissions.delete(sessionId);
409
+ if (pendingSubmission !== undefined) {
410
+ this.cancelledPromptSubmissions.delete(pendingSubmission);
411
+ }
273
412
  this.runningBashProcs.delete(sessionId);
274
413
  this.attachmentLabelCache.delete(sessionId);
414
+ this.agentCommandSnapshots.delete(sessionId);
275
415
  // Clean pending permissions for this session
276
416
  for (const [reqId, perm] of this.pendingPermissions) {
277
417
  if (perm.sessionId === sessionId)
@@ -296,17 +436,17 @@ export class SessionManager {
296
436
  /** Flush only the assistant message buffer to store. */
297
437
  flushAssistantBuffer(sessionId) {
298
438
  const assistant = this.assistantBuffers.get(sessionId);
299
- if (assistant) {
439
+ this.assistantBuffers.delete(sessionId);
440
+ if (assistant && this.store.getSession(sessionId)) {
300
441
  this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
301
- this.assistantBuffers.delete(sessionId);
302
442
  }
303
443
  }
304
444
  /** Flush only the thinking buffer to store. */
305
445
  flushThinkingBuffer(sessionId) {
306
446
  const thinking = this.thinkingBuffers.get(sessionId);
307
- if (thinking) {
447
+ this.thinkingBuffers.delete(sessionId);
448
+ if (thinking && this.store.getSession(sessionId)) {
308
449
  this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
309
- this.thinkingBuffers.delete(sessionId);
310
450
  }
311
451
  }
312
452
  /** Append to assistant message buffer. */
@@ -324,12 +464,52 @@ export class SessionManager {
324
464
  return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
325
465
  }
326
466
  getBusyKind(sessionId) {
327
- if (this.runningBashProcs.has(sessionId))
328
- return "bash";
467
+ if (this.pendingPromptSubmissions.has(sessionId))
468
+ return "agent";
329
469
  if (this.activePrompts.has(sessionId))
330
470
  return "agent";
471
+ if (this.runningBashProcs.has(sessionId))
472
+ return "bash";
331
473
  return null;
332
474
  }
475
+ /**
476
+ * True when `promptId` still names the session's live turn. A turn can
477
+ * outlive its own supersession — cancelling one and immediately starting
478
+ * another interleaves them — and its terminal work must not clear state
479
+ * that now belongs to the replacement. An absent id is treated as current
480
+ * so callers predating turn identity keep their old behaviour.
481
+ */
482
+ isCurrentPrompt(sessionId, promptId) {
483
+ if (!promptId)
484
+ return true;
485
+ const current = this.state.getState(sessionId).runtime.busy?.promptId;
486
+ return current == null || current === promptId;
487
+ }
488
+ reservePromptSubmission(sessionId) {
489
+ if (this.getBusyKind(sessionId) !== null)
490
+ return null;
491
+ const submissionId = ++this.nextPromptSubmissionNumber;
492
+ this.pendingPromptSubmissions.set(sessionId, submissionId);
493
+ return submissionId;
494
+ }
495
+ cancelPendingPromptSubmission(sessionId) {
496
+ const submissionId = this.pendingPromptSubmissions.get(sessionId);
497
+ if (submissionId === undefined)
498
+ return false;
499
+ this.cancelledPromptSubmissions.add(submissionId);
500
+ return true;
501
+ }
502
+ isPromptSubmissionCancelled(submissionId) {
503
+ return this.cancelledPromptSubmissions.has(submissionId);
504
+ }
505
+ releasePromptSubmission(sessionId, submissionId, sync = true) {
506
+ if (this.pendingPromptSubmissions.get(sessionId) === submissionId) {
507
+ this.pendingPromptSubmissions.delete(sessionId);
508
+ }
509
+ this.cancelledPromptSubmissions.delete(submissionId);
510
+ if (sync)
511
+ this.syncBusy(sessionId);
512
+ }
333
513
  /**
334
514
  * Recompute busy from active prompts/bash procs and patch the state manager.
335
515
  * Call this immediately after mutating activePrompts / runningBashProcs so
@@ -348,8 +528,14 @@ export class SessionManager {
348
528
  this.state.clearCancelSafety(sessionId);
349
529
  return;
350
530
  }
351
- const nextPromptId = kind === "agent" ? (promptId ?? current?.promptId ?? null) : null;
352
- if (current?.kind === kind && current.promptId === nextPromptId)
531
+ const nextPromptId = kind === "agent"
532
+ ? (promptId ??
533
+ (current?.kind === "agent"
534
+ ? current.promptId
535
+ : `prompt-${++this.nextPromptNumber}`))
536
+ : null;
537
+ const sameWork = current?.kind === kind && current.promptId === nextPromptId;
538
+ if (sameWork)
353
539
  return;
354
540
  this.state.patch(sessionId, {
355
541
  runtime: {
@@ -357,6 +543,7 @@ export class SessionManager {
357
543
  kind: kind,
358
544
  since: current?.kind === kind ? current.since : new Date().toISOString(),
359
545
  promptId: nextPromptId,
546
+ cancelStatus: null,
360
547
  },
361
548
  },
362
549
  });
@@ -375,13 +562,16 @@ export class SessionManager {
375
562
  });
376
563
  this.activePrompts.add(sessionId);
377
564
  this.syncBusy(sessionId);
565
+ const promptId = this.state.getState(sessionId).runtime.busy?.promptId ?? undefined;
378
566
  bridge
379
- .prompt(sessionId, "Continue your previous response — it was interrupted mid-way.")
567
+ .prompt(sessionId, "Continue your previous response — it was interrupted mid-way.", undefined, promptId)
380
568
  .catch((err) => {
381
569
  slog.error("auto-retry failed", {
382
570
  sessionId: sessionId.slice(0, 8) + "…",
383
571
  error: err,
384
572
  });
573
+ if (!this.isCurrentPrompt(sessionId, promptId))
574
+ return;
385
575
  this.activePrompts.delete(sessionId);
386
576
  this.syncBusy(sessionId);
387
577
  });
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Per-session runtime state: single source of truth for "what state is this
3
- * session in right now" (busy / streaming / pending permissions).
3
+ * session in right now" (busy / streaming / pending permissions / plan).
4
4
  *
5
5
  * The frontend fetches a full snapshot on connect / reconnect / after long
6
6
  * backgrounding, then applies incremental `state_patch` SSE events. This
7
7
  * replaces the old "replay history + reconcile" approach which repeatedly
8
8
  * grew one-off sync paths per state field.
9
9
  */
10
+ import { log } from "./log.js";
11
+ const clog = log.scope("cancel");
10
12
  function defaultState() {
11
13
  return {
12
14
  seq: 0,
@@ -14,6 +16,7 @@ function defaultState() {
14
16
  busy: null,
15
17
  pendingPermissions: [],
16
18
  streaming: { assistant: false, thinking: false },
19
+ plan: null,
17
20
  },
18
21
  };
19
22
  }
@@ -22,7 +25,10 @@ function busyEqual(a, b) {
22
25
  return true;
23
26
  if (a === null || b === null)
24
27
  return false;
25
- return a.kind === b.kind && a.since === b.since && a.promptId === b.promptId;
28
+ return (a.kind === b.kind &&
29
+ a.since === b.since &&
30
+ a.promptId === b.promptId &&
31
+ (a.cancelStatus ?? null) === (b.cancelStatus ?? null));
26
32
  }
27
33
  function permsEqual(a, b) {
28
34
  if (a.length !== b.length)
@@ -43,6 +49,13 @@ function permsEqual(a, b) {
43
49
  }
44
50
  return true;
45
51
  }
52
+ function plansEqual(a, b) {
53
+ if (a === null || b === null)
54
+ return a === b;
55
+ if (a.length !== b.length)
56
+ return false;
57
+ return a.every((entry, index) => entry.status === b[index].status && entry.content === b[index].content);
58
+ }
46
59
  /** True when the patch would change the current runtime state. */
47
60
  function hasRuntimeChanges(current, patch) {
48
61
  if (!patch)
@@ -53,6 +66,8 @@ function hasRuntimeChanges(current, patch) {
53
66
  patch.pendingPermissions &&
54
67
  !permsEqual(current.pendingPermissions, patch.pendingPermissions))
55
68
  return true;
69
+ if ("plan" in patch && !plansEqual(current.plan, patch.plan ?? null))
70
+ return true;
56
71
  if ("streaming" in patch && patch.streaming) {
57
72
  const s = patch.streaming;
58
73
  if (s.assistant !== undefined &&
@@ -76,6 +91,11 @@ export class SessionStateManager {
76
91
  }
77
92
  return s;
78
93
  }
94
+ /** Read streaming state without creating runtime state for an unseen session. */
95
+ peekStreaming(sessionId) {
96
+ const streaming = this.states.get(sessionId)?.runtime.streaming;
97
+ return streaming ? { ...streaming } : { assistant: false, thinking: false };
98
+ }
79
99
  /**
80
100
  * Merge a patch into the session's runtime state. Bumps seq and notifies
81
101
  * listeners only when the patch actually changes something (no-op patches
@@ -95,6 +115,10 @@ export class SessionStateManager {
95
115
  state.runtime.pendingPermissions =
96
116
  patch.runtime.pendingPermissions.slice();
97
117
  }
118
+ if ("plan" in patch.runtime) {
119
+ state.runtime.plan =
120
+ patch.runtime.plan?.map((entry) => ({ ...entry })) ?? null;
121
+ }
98
122
  if ("streaming" in patch.runtime && patch.runtime.streaming) {
99
123
  if (patch.runtime.streaming.assistant !== undefined) {
100
124
  state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
@@ -130,9 +154,30 @@ export class SessionStateManager {
130
154
  this.cancelTimers.delete(sessionId);
131
155
  }
132
156
  }
157
+ /** Clear current plans for every known session (used on bridge reload). */
158
+ clearPlans() {
159
+ for (const [sessionId, state] of this.states) {
160
+ if (state.runtime.plan !== null) {
161
+ this.patch(sessionId, { runtime: { plan: null } });
162
+ }
163
+ }
164
+ }
165
+ /** Clear active stream markers for every known session on bridge teardown. */
166
+ clearStreaming() {
167
+ for (const [sessionId, state] of this.states) {
168
+ if (state.runtime.streaming.assistant ||
169
+ state.runtime.streaming.thinking) {
170
+ this.patch(sessionId, {
171
+ runtime: {
172
+ streaming: { assistant: false, thinking: false },
173
+ },
174
+ });
175
+ }
176
+ }
177
+ }
133
178
  /**
134
- * Backend safety net for cancel: if busy is still set after `timeoutMs`,
135
- * force-clear it. Replaces the old frontend cancel timer.
179
+ * Backend acknowledgement timer for cancel: if the same agent prompt is
180
+ * still pending after `timeoutMs`, mark the request unconfirmed.
136
181
  * A second arm on the same session replaces the existing timer.
137
182
  */
138
183
  armCancelSafety(sessionId, timeoutMs) {
@@ -143,12 +188,32 @@ export class SessionStateManager {
143
188
  clearTimeout(existing);
144
189
  const t = setTimeout(() => {
145
190
  this.cancelTimers.delete(sessionId);
146
- this.patch(sessionId, { runtime: { busy: null } });
191
+ const busy = this.getState(sessionId).runtime.busy;
192
+ if (busy?.kind === "agent" && busy.cancelStatus === "requested") {
193
+ clog.warn("agent did not acknowledge", {
194
+ sessionId: sessionId.slice(0, 8),
195
+ promptId: busy.promptId,
196
+ });
197
+ this.patch(sessionId, {
198
+ runtime: {
199
+ busy: { ...busy, cancelStatus: "unconfirmed" },
200
+ },
201
+ });
202
+ }
147
203
  }, timeoutMs);
148
204
  if (typeof t === "object" && "unref" in t)
149
205
  t.unref();
150
206
  this.cancelTimers.set(sessionId, t);
151
207
  }
208
+ /** Mark that a cancel notification was sent for the active agent prompt. */
209
+ markCancelRequested(sessionId) {
210
+ const busy = this.getState(sessionId).runtime.busy;
211
+ if (busy?.kind !== "agent")
212
+ return;
213
+ this.patch(sessionId, {
214
+ runtime: { busy: { ...busy, cancelStatus: "requested" } },
215
+ });
216
+ }
152
217
  /** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
153
218
  clearCancelSafety(sessionId) {
154
219
  const t = this.cancelTimers.get(sessionId);