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.
- package/dist/server/__tests__/acp-session.test.js +23 -0
- package/dist/server/__tests__/session-manager.test.js +165 -0
- package/dist/server/acp-session.js +1 -1
- package/dist/server/index.js +3 -0
- package/dist/server/session-manager.js +195 -11
- package/dist/web/assets/index-Bll9nc_X.js +21 -0
- package/dist/web/assets/index-y1qgSOLv.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B-YXVUoQ.css +0 -1
- package/dist/web/assets/index-Bu0K7QgY.js +0 -21
|
@@ -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) {
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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 =
|
|
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;
|