leduo-patrol 2.2.3 → 2.2.4

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.
@@ -73,6 +73,29 @@ test("ClaudeAcpSession.findRestorableSession falls back to the first session whe
73
73
  const result = await session.findRestorableSession();
74
74
  assert.equal(result, "restorable-session-id");
75
75
  });
76
+ test("ClaudeAcpSession.prompt emits prompt_started with images", async () => {
77
+ const events = [];
78
+ const session = new ClaudeAcpSession({
79
+ workspacePath: "/tmp/workspace",
80
+ agentBinPath: "claude-code-acp",
81
+ onEvent: (event) => events.push(event),
82
+ });
83
+ let promptPayload = null;
84
+ session.connection = {
85
+ prompt: async (payload) => {
86
+ promptPayload = payload;
87
+ return { stopReason: "end_turn" };
88
+ },
89
+ };
90
+ session.sessionId = "session-1";
91
+ session.sessionPromise = Promise.resolve("session-1");
92
+ session.waitForDrain = async () => undefined;
93
+ await session.prompt("hello", [{ data: "abc", mimeType: "image/png" }]);
94
+ const started = events[0];
95
+ assert.equal(started.type, "prompt_started");
96
+ assert.deepEqual(started.payload.images, [{ data: "abc", mimeType: "image/png" }]);
97
+ assert.deepEqual(promptPayload.prompt.map((block) => block.type), ["image", "text"]);
98
+ });
76
99
  test("ClaudeAcpSession.connect rejects gracefully when the ACP agent spawn emits EAGAIN", async (t) => {
77
100
  const fakeChild = new EventEmitter();
78
101
  fakeChild.stdin = new PassThrough();
@@ -1,5 +1,8 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
6
  import { SessionManager, sessionManagerTestables } from "../session-manager.js";
4
7
  function makeSnapshot(overrides = {}) {
5
8
  return {
@@ -24,6 +27,23 @@ function makeEntry(overrides = {}) {
24
27
  acpFullTimeline: [],
25
28
  outputBuffer: "",
26
29
  switchInProgress: false,
30
+ acpQueueDrainActive: false,
31
+ };
32
+ }
33
+ function makeAcpState(overrides = {}) {
34
+ return {
35
+ modes: ["default"],
36
+ defaultModeId: "default",
37
+ currentModeId: "default",
38
+ busy: false,
39
+ timeline: [],
40
+ historyTotal: 0,
41
+ historyStart: 0,
42
+ permissions: [],
43
+ questions: [],
44
+ availableCommands: [],
45
+ queuedPrompts: [],
46
+ ...overrides,
27
47
  };
28
48
  }
29
49
  function makeManager(options) {
@@ -147,6 +167,7 @@ test("SessionManager.switchEngine rejects pending ACP approvals", async () => {
147
167
  }],
148
168
  questions: [],
149
169
  availableCommands: [],
170
+ queuedPrompts: [],
150
171
  },
151
172
  }));
152
173
  manager.startEngine = async () => undefined;
@@ -171,6 +192,7 @@ test("SessionManager.switchEngine allows idle ACP sessions despite stale activit
171
192
  permissions: [],
172
193
  questions: [],
173
194
  availableCommands: [],
195
+ queuedPrompts: [],
174
196
  },
175
197
  }));
176
198
  manager.startEngine = async (entry, resume) => {
@@ -206,6 +228,7 @@ test("SessionManager.switchEngine allows ACP sessions after end_turn even if bus
206
228
  permissions: [],
207
229
  questions: [],
208
230
  availableCommands: [],
231
+ queuedPrompts: [],
209
232
  },
210
233
  }));
211
234
  manager.startEngine = async (entry, resume) => {
@@ -219,3 +242,145 @@ test("SessionManager.switchEngine allows ACP sessions after end_turn even if bus
219
242
  assert.deepEqual(stopped, ["acp"]);
220
243
  assert.deepEqual(started, [{ engine: "cli", resume: true }]);
221
244
  });
245
+ test("SessionManager.prompt queues ACP prompts while busy", async () => {
246
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
247
+ const promptCalls = [];
248
+ manager.sessions.set("s1", {
249
+ ...makeEntry({
250
+ engine: "acp",
251
+ acp: makeAcpState({ busy: true }),
252
+ }),
253
+ acpSession: {
254
+ setMode: async () => undefined,
255
+ prompt: async () => {
256
+ promptCalls.push("prompt");
257
+ },
258
+ },
259
+ });
260
+ await manager.prompt("s1", "queued hello");
261
+ const entry = manager.sessions.get("s1");
262
+ assert.equal(promptCalls.length, 0);
263
+ assert.equal(entry.snapshot.acp?.queuedPrompts.length, 1);
264
+ assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.text, "queued hello");
265
+ assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.status, "queued");
266
+ assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.modeId, "default");
267
+ });
268
+ test("SessionManager.prompt sends immediately when ACP session is idle", async () => {
269
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
270
+ const setModeCalls = [];
271
+ const promptCalls = [];
272
+ manager.sessions.set("s1", {
273
+ ...makeEntry({
274
+ engine: "acp",
275
+ acp: makeAcpState(),
276
+ }),
277
+ acpSession: {
278
+ setMode: async (modeId) => {
279
+ setModeCalls.push(modeId);
280
+ },
281
+ prompt: async (text, images) => {
282
+ promptCalls.push({ text, images });
283
+ },
284
+ },
285
+ });
286
+ await manager.prompt("s1", "ship it", "plan", [{ data: "base64", mimeType: "image/png" }]);
287
+ const entry = manager.sessions.get("s1");
288
+ assert.deepEqual(setModeCalls, ["plan"]);
289
+ assert.deepEqual(promptCalls, [{ text: "ship it", images: [{ data: "base64", mimeType: "image/png" }] }]);
290
+ assert.equal(entry.snapshot.acp?.queuedPrompts.length, 0);
291
+ });
292
+ test("SessionManager.prompt keeps strict FIFO when queued prompts already exist", async () => {
293
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
294
+ const promptCalls = [];
295
+ manager.sessions.set("s1", {
296
+ ...makeEntry({
297
+ engine: "acp",
298
+ acp: makeAcpState({
299
+ queuedPrompts: [{
300
+ id: "queued-1",
301
+ text: "first",
302
+ images: [],
303
+ modeId: "default",
304
+ createdAt: new Date().toISOString(),
305
+ status: "queued",
306
+ }],
307
+ }),
308
+ }),
309
+ acpSession: {
310
+ setMode: async () => undefined,
311
+ prompt: async () => {
312
+ promptCalls.push("prompt");
313
+ },
314
+ },
315
+ });
316
+ await manager.prompt("s1", "second");
317
+ const queuedPrompts = manager.sessions.get("s1").snapshot.acp?.queuedPrompts ?? [];
318
+ assert.equal(promptCalls.length, 1);
319
+ assert.deepEqual(queuedPrompts.map((prompt) => prompt.text), ["first", "second"]);
320
+ assert.deepEqual(queuedPrompts.map((prompt) => prompt.status), ["sending", "queued"]);
321
+ });
322
+ test("SessionManager.switchEngine rejects ACP sessions with queued prompts", async () => {
323
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
324
+ manager.sessions.set("s1", makeEntry({
325
+ engine: "acp",
326
+ acp: makeAcpState({
327
+ queuedPrompts: [{
328
+ id: "queued-1",
329
+ text: "first",
330
+ images: [],
331
+ modeId: "default",
332
+ createdAt: new Date().toISOString(),
333
+ status: "queued",
334
+ }],
335
+ }),
336
+ }));
337
+ await assert.rejects(() => manager.switchEngine("s1", "cli"), /Session is not switchable: 队列未清空/);
338
+ });
339
+ test("SessionManager recovers persisted ACP queues and drains them on initialize", async () => {
340
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
341
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "leduo-patrol-session-manager-"));
342
+ const stateFilePath = path.join(tempDir, "state.json");
343
+ const promptCalls = [];
344
+ const setModeCalls = [];
345
+ await writeFile(stateFilePath, JSON.stringify({
346
+ sessions: [{
347
+ clientSessionId: "persisted-acp",
348
+ title: "persisted",
349
+ workspacePath: process.cwd(),
350
+ sessionId: "shared-session-id",
351
+ engine: "acp",
352
+ updatedAt: new Date().toISOString(),
353
+ acpDefaultModeId: "default",
354
+ acpCurrentModeId: "default",
355
+ acpQueuedPrompts: [{
356
+ id: "queued-1",
357
+ text: "resume me",
358
+ images: [{ data: "abc", mimeType: "image/png" }],
359
+ modeId: "plan",
360
+ createdAt: new Date().toISOString(),
361
+ status: "sending",
362
+ }],
363
+ }],
364
+ }), "utf8");
365
+ manager.stateFilePath = stateFilePath;
366
+ manager.isRestorableWorkspace = async () => true;
367
+ manager.startHistoryMonitor = async () => undefined;
368
+ manager.startEngine = async (entry) => {
369
+ entry.snapshot.connectionState = "connected";
370
+ entry.acpSession = {
371
+ setMode: async (modeId) => {
372
+ setModeCalls.push(modeId);
373
+ },
374
+ prompt: async (text, images) => {
375
+ promptCalls.push({ text, images });
376
+ },
377
+ };
378
+ manager.recoverSendingQueuedPrompts(entry);
379
+ await manager.drainAcpPromptQueue(entry);
380
+ };
381
+ await manager.initialize();
382
+ const restoredEntry = manager.sessions.get("persisted-acp");
383
+ assert.deepEqual(setModeCalls, ["plan"]);
384
+ assert.deepEqual(promptCalls, [{ text: "resume me", images: [{ data: "abc", mimeType: "image/png" }] }]);
385
+ assert.equal(restoredEntry.snapshot.acp?.queuedPrompts[0]?.status, "sending");
386
+ });
@@ -246,7 +246,7 @@ export class ClaudeAcpSession {
246
246
  }
247
247
  this.activePrompt = true;
248
248
  const promptId = randomUUID();
249
- this.onEvent({ type: "prompt_started", payload: { promptId, text } });
249
+ this.onEvent({ type: "prompt_started", payload: { promptId, text, images } });
250
250
  try {
251
251
  const promptContent = [];
252
252
  if (images && images.length > 0) {
@@ -232,6 +232,9 @@ wss.on("connection", (socket, request) => {
232
232
  case "cancel":
233
233
  await sessionManager.cancel(message.payload.clientSessionId);
234
234
  break;
235
+ case "remove_queued_prompt":
236
+ await sessionManager.removeQueuedPrompt(message.payload.clientSessionId, message.payload.promptId);
237
+ break;
235
238
  case "permission":
236
239
  await sessionManager.resolvePermission(message.payload.clientSessionId, message.payload.requestId, message.payload.optionId, message.payload.note);
237
240
  break;
@@ -77,7 +77,7 @@ export class SessionManager {
77
77
  updatedAt: persisted.updatedAt,
78
78
  allowSkipPermissions: persisted.allowSkipPermissions,
79
79
  acp: persisted.engine === "acp"
80
- ? createEmptyAcpState(persisted.acpDefaultModeId, persisted.acpCurrentModeId)
80
+ ? createEmptyAcpState(persisted.acpDefaultModeId, persisted.acpCurrentModeId, normalizeQueuedPrompts(persisted.acpQueuedPrompts))
81
81
  : undefined,
82
82
  };
83
83
  const entry = {
@@ -88,6 +88,7 @@ export class SessionManager {
88
88
  acpFullTimeline: [],
89
89
  outputBuffer: "",
90
90
  switchInProgress: false,
91
+ acpQueueDrainActive: false,
91
92
  };
92
93
  this.sessions.set(snapshot.clientSessionId, entry);
93
94
  if (snapshot.sessionId) {
@@ -176,6 +177,7 @@ export class SessionManager {
176
177
  acpFullTimeline: [],
177
178
  outputBuffer: "",
178
179
  switchInProgress: false,
180
+ acpQueueDrainActive: false,
179
181
  };
180
182
  this.sessions.set(snapshot.clientSessionId, entry);
181
183
  if (snapshot.sessionId) {
@@ -282,14 +284,21 @@ export class SessionManager {
282
284
  await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
283
285
  }
284
286
  const acpState = this.ensureAcpState(entry);
285
- const effectiveModeId = modeId || acpState.defaultModeId;
286
- if (effectiveModeId) {
287
- await entry.acpSession?.setMode(effectiveModeId);
288
- acpState.currentModeId = effectiveModeId;
287
+ const effectiveModeId = modeId || acpState.currentModeId || acpState.defaultModeId || "default";
288
+ const nextImages = images ?? [];
289
+ if (this.shouldQueueAcpPrompt(entry)) {
290
+ this.enqueueAcpPrompt(entry, text, nextImages, effectiveModeId);
291
+ void this.drainAcpPromptQueue(entry);
292
+ return;
289
293
  }
290
- entry.snapshot.updatedAt = new Date().toISOString();
291
- this.schedulePersist();
292
- await entry.acpSession?.prompt(text, images);
294
+ await this.sendAcpPrompt(entry, {
295
+ id: randomUUID(),
296
+ text,
297
+ images: nextImages,
298
+ modeId: effectiveModeId,
299
+ createdAt: new Date().toISOString(),
300
+ status: "sending",
301
+ });
293
302
  }
294
303
  async setSessionMode(clientSessionId, modeId) {
295
304
  const entry = this.getEntry(clientSessionId);
@@ -352,6 +361,24 @@ export class SessionManager {
352
361
  }
353
362
  await entry.acpSession?.answerQuestion(questionId, answer);
354
363
  }
364
+ async removeQueuedPrompt(clientSessionId, promptId) {
365
+ const entry = this.getEntry(clientSessionId);
366
+ if (entry.snapshot.engine !== "acp") {
367
+ throw new Error("Queued prompts are only available in ACP mode.");
368
+ }
369
+ const acpState = this.ensureAcpState(entry);
370
+ const prompt = acpState.queuedPrompts.find((item) => item.id === promptId);
371
+ if (!prompt) {
372
+ return;
373
+ }
374
+ if (prompt.status === "sending") {
375
+ throw new Error("Queued prompt is currently sending and cannot be removed.");
376
+ }
377
+ acpState.queuedPrompts = acpState.queuedPrompts.filter((item) => item.id !== promptId);
378
+ entry.snapshot.updatedAt = new Date().toISOString();
379
+ this.schedulePersist();
380
+ this.emitSessionUpdated(entry);
381
+ }
355
382
  async closeSession(clientSessionId) {
356
383
  const entry = this.sessions.get(clientSessionId);
357
384
  if (!entry) {
@@ -368,6 +395,112 @@ export class SessionManager {
368
395
  payload: { clientSessionId },
369
396
  });
370
397
  }
398
+ shouldQueueAcpPrompt(entry) {
399
+ const acpState = this.ensureAcpState(entry);
400
+ return (entry.acpQueueDrainActive
401
+ || acpState.queuedPrompts.length > 0
402
+ || acpState.permissions.length > 0
403
+ || acpState.questions.length > 0
404
+ || this.isAcpBusy(acpState));
405
+ }
406
+ enqueueAcpPrompt(entry, text, images, modeId) {
407
+ const acpState = this.ensureAcpState(entry);
408
+ acpState.queuedPrompts.push({
409
+ id: randomUUID(),
410
+ text,
411
+ images,
412
+ modeId,
413
+ createdAt: new Date().toISOString(),
414
+ status: "queued",
415
+ });
416
+ entry.snapshot.updatedAt = new Date().toISOString();
417
+ this.schedulePersist();
418
+ this.emitSessionUpdated(entry);
419
+ }
420
+ async sendAcpPrompt(entry, prompt) {
421
+ if (!entry.acpSession) {
422
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
423
+ }
424
+ const acpState = this.ensureAcpState(entry);
425
+ if (prompt.modeId) {
426
+ await entry.acpSession?.setMode(prompt.modeId);
427
+ acpState.currentModeId = prompt.modeId;
428
+ }
429
+ entry.snapshot.updatedAt = new Date().toISOString();
430
+ this.schedulePersist();
431
+ await entry.acpSession?.prompt(prompt.text, prompt.images);
432
+ }
433
+ canDrainAcpPromptQueue(entry) {
434
+ if (entry.snapshot.engine !== "acp" || entry.snapshot.connectionState !== "connected") {
435
+ return false;
436
+ }
437
+ if (!entry.acpSession || entry.acpQueueDrainActive) {
438
+ return false;
439
+ }
440
+ const acpState = this.ensureAcpState(entry);
441
+ return (acpState.permissions.length === 0
442
+ && acpState.questions.length === 0
443
+ && !this.isAcpBusy(acpState)
444
+ && acpState.queuedPrompts.length > 0);
445
+ }
446
+ async drainAcpPromptQueue(entry) {
447
+ if (!this.canDrainAcpPromptQueue(entry)) {
448
+ return;
449
+ }
450
+ const acpState = this.ensureAcpState(entry);
451
+ const nextPrompt = acpState.queuedPrompts[0];
452
+ if (!nextPrompt) {
453
+ return;
454
+ }
455
+ const drainingPromptId = nextPrompt.id;
456
+ entry.acpQueueDrainActive = true;
457
+ try {
458
+ if (nextPrompt.status !== "sending") {
459
+ nextPrompt.status = "sending";
460
+ entry.snapshot.updatedAt = new Date().toISOString();
461
+ this.schedulePersist();
462
+ this.emitSessionUpdated(entry);
463
+ }
464
+ await this.sendAcpPrompt(entry, nextPrompt);
465
+ }
466
+ catch (error) {
467
+ if (!isAcpPromptBusyError(error)) {
468
+ this.handleManagerError(entry.snapshot.clientSessionId, error);
469
+ }
470
+ }
471
+ finally {
472
+ entry.acpQueueDrainActive = false;
473
+ if (this.canDrainAcpPromptQueue(entry) && this.ensureAcpState(entry).queuedPrompts[0]?.id !== drainingPromptId) {
474
+ void this.drainAcpPromptQueue(entry);
475
+ }
476
+ }
477
+ }
478
+ recoverSendingQueuedPrompts(entry) {
479
+ const acpState = this.ensureAcpState(entry);
480
+ if (acpState.queuedPrompts.length === 0) {
481
+ return false;
482
+ }
483
+ let changed = false;
484
+ const canResumeQueueHead = acpState.permissions.length === 0
485
+ && acpState.questions.length === 0
486
+ && !this.isAcpBusy(acpState);
487
+ acpState.queuedPrompts = acpState.queuedPrompts.map((prompt, index) => {
488
+ if (prompt.status !== "sending") {
489
+ return prompt;
490
+ }
491
+ if (index === 0 && canResumeQueueHead) {
492
+ changed = true;
493
+ return { ...prompt, status: "queued" };
494
+ }
495
+ return prompt;
496
+ });
497
+ if (changed) {
498
+ entry.snapshot.updatedAt = new Date().toISOString();
499
+ this.schedulePersist();
500
+ this.emitSessionUpdated(entry);
501
+ }
502
+ return changed;
503
+ }
371
504
  async startEngine(entry, resume) {
372
505
  entry.snapshot.connectionState = "connecting";
373
506
  entry.snapshot.updatedAt = new Date().toISOString();
@@ -379,6 +512,7 @@ export class SessionManager {
379
512
  await this.startAcpSession(entry, resume);
380
513
  }
381
514
  async stopEngine(entry, reason) {
515
+ entry.acpQueueDrainActive = false;
382
516
  if (reason === "switch") {
383
517
  entry.outputBuffer = "";
384
518
  }
@@ -480,6 +614,8 @@ export class SessionManager {
480
614
  entry.snapshot.connectionState = "connected";
481
615
  entry.snapshot.updatedAt = new Date().toISOString();
482
616
  this.schedulePersist();
617
+ this.recoverSendingQueuedPrompts(entry);
618
+ void this.drainAcpPromptQueue(entry);
483
619
  }
484
620
  handleAcpSessionEvent(clientSessionId, event) {
485
621
  const entry = this.sessions.get(clientSessionId);
@@ -534,14 +670,19 @@ export class SessionManager {
534
670
  kind: "user",
535
671
  title: "你",
536
672
  body: event.payload.text,
673
+ images: event.payload.images,
537
674
  });
538
675
  break;
539
676
  case "prompt_finished":
540
- acpState.busy = false;
677
+ acpState.busy = shouldKeepAcpSessionRunningAfterPromptFinished(event.payload.stopReason);
678
+ if (acpState.queuedPrompts[0]?.status === "sending") {
679
+ acpState.queuedPrompts.shift();
680
+ shouldEmitFullSnapshot = true;
681
+ }
541
682
  this.appendTimeline(entry, {
542
683
  id: randomUUID(),
543
684
  kind: "system",
544
- title: "本轮完成",
685
+ title: acpState.busy ? "等待待处理中" : "本轮完成",
545
686
  body: event.payload.stopReason,
546
687
  });
547
688
  break;
@@ -733,6 +874,7 @@ export class SessionManager {
733
874
  clientSessionId,
734
875
  promptId: event.payload.promptId,
735
876
  text: event.payload.text,
877
+ images: event.payload.images,
736
878
  },
737
879
  });
738
880
  break;
@@ -824,6 +966,9 @@ export class SessionManager {
824
966
  default:
825
967
  break;
826
968
  }
969
+ if (event.type === "prompt_finished" || event.type === "permission_resolved" || event.type === "question_answered") {
970
+ void this.drainAcpPromptQueue(entry);
971
+ }
827
972
  }
828
973
  consumeSessionUpdate(entry, update) {
829
974
  const acpState = this.ensureAcpState(entry);
@@ -984,6 +1129,8 @@ export class SessionManager {
984
1129
  return "待提问";
985
1130
  if (this.isAcpBusy(acpState))
986
1131
  return "运行中";
1132
+ if (acpState.queuedPrompts.length > 0)
1133
+ return "队列未清空";
987
1134
  return null;
988
1135
  }
989
1136
  isAcpBusy(acpState) {
@@ -1016,6 +1163,7 @@ export class SessionManager {
1016
1163
  allowSkipPermissions: session.allowSkipPermissions,
1017
1164
  acpDefaultModeId: session.acp?.defaultModeId,
1018
1165
  acpCurrentModeId: session.acp?.currentModeId,
1166
+ acpQueuedPrompts: session.acp?.queuedPrompts ?? [],
1019
1167
  }));
1020
1168
  await mkdir(path.dirname(this.stateFilePath), { recursive: true });
1021
1169
  await writeFile(this.stateFilePath, JSON.stringify({ sessions: persistedSessions }, null, 2), "utf8");
@@ -1300,7 +1448,7 @@ export class SessionManager {
1300
1448
  });
1301
1449
  }
1302
1450
  }
1303
- function createEmptyAcpState(defaultModeId = "default", currentModeId = defaultModeId) {
1451
+ function createEmptyAcpState(defaultModeId = "default", currentModeId = defaultModeId, queuedPrompts = []) {
1304
1452
  return {
1305
1453
  modes: [],
1306
1454
  defaultModeId,
@@ -1312,8 +1460,44 @@ function createEmptyAcpState(defaultModeId = "default", currentModeId = defaultM
1312
1460
  permissions: [],
1313
1461
  questions: [],
1314
1462
  availableCommands: [],
1463
+ queuedPrompts,
1315
1464
  };
1316
1465
  }
1466
+ function normalizeQueuedPrompts(rawQueuedPrompts) {
1467
+ if (!Array.isArray(rawQueuedPrompts)) {
1468
+ return [];
1469
+ }
1470
+ return rawQueuedPrompts.flatMap((rawPrompt) => {
1471
+ const record = asRecord(rawPrompt);
1472
+ if (!record) {
1473
+ return [];
1474
+ }
1475
+ const id = typeof record.id === "string" && record.id.trim() ? record.id : randomUUID();
1476
+ const text = typeof record.text === "string" ? record.text : "";
1477
+ const images = Array.isArray(record.images)
1478
+ ? record.images.flatMap((rawImage) => {
1479
+ const image = asRecord(rawImage);
1480
+ if (!image || typeof image.data !== "string" || typeof image.mimeType !== "string") {
1481
+ return [];
1482
+ }
1483
+ return [{ data: image.data, mimeType: image.mimeType }];
1484
+ })
1485
+ : [];
1486
+ const modeId = typeof record.modeId === "string" && record.modeId.trim() ? record.modeId : "default";
1487
+ const createdAt = typeof record.createdAt === "string" && record.createdAt.trim()
1488
+ ? record.createdAt
1489
+ : new Date().toISOString();
1490
+ const status = record.status === "sending" ? "sending" : "queued";
1491
+ return [{ id, text, images, modeId, createdAt, status }];
1492
+ });
1493
+ }
1494
+ function shouldKeepAcpSessionRunningAfterPromptFinished(stopReason) {
1495
+ const normalized = stopReason.trim().toLowerCase();
1496
+ return normalized === "pause_turn" || normalized === "pause-turn" || normalized.includes("permission");
1497
+ }
1498
+ function isAcpPromptBusyError(error) {
1499
+ return error instanceof Error && error.message.includes("Another Claude prompt is still running.");
1500
+ }
1317
1501
  function stringifyMaybe(value) {
1318
1502
  if (typeof value === "string") {
1319
1503
  return value;