@co0ontty/wand 3.1.1 → 4.0.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.
Files changed (62) hide show
  1. package/dist/auth.d.ts +19 -5
  2. package/dist/auth.js +83 -45
  3. package/dist/build-info.json +3 -3
  4. package/dist/cert.d.ts +1 -1
  5. package/dist/cert.js +124 -74
  6. package/dist/config.js +25 -8
  7. package/dist/express-async.d.ts +6 -0
  8. package/dist/express-async.js +28 -0
  9. package/dist/git-quick-commit.d.ts +2 -0
  10. package/dist/git-quick-commit.js +215 -76
  11. package/dist/git-utils.d.ts +4 -0
  12. package/dist/git-utils.js +60 -11
  13. package/dist/git-worktree.d.ts +8 -1
  14. package/dist/git-worktree.js +406 -41
  15. package/dist/models.d.ts +34 -4
  16. package/dist/models.js +334 -48
  17. package/dist/process-manager.d.ts +22 -30
  18. package/dist/process-manager.js +374 -441
  19. package/dist/provider-history-scanner.d.ts +54 -0
  20. package/dist/provider-history-scanner.js +354 -0
  21. package/dist/request-limits.d.ts +1 -0
  22. package/dist/request-limits.js +8 -0
  23. package/dist/resume-policy.d.ts +2 -0
  24. package/dist/resume-policy.js +5 -0
  25. package/dist/runtime-config.d.ts +16 -0
  26. package/dist/runtime-config.js +49 -0
  27. package/dist/server-file-routes.d.ts +17 -0
  28. package/dist/server-file-routes.js +653 -0
  29. package/dist/server-session-routes.d.ts +16 -3
  30. package/dist/server-session-routes.js +170 -149
  31. package/dist/server-settings-routes.d.ts +43 -0
  32. package/dist/server-settings-routes.js +225 -0
  33. package/dist/server-update-routes.d.ts +61 -0
  34. package/dist/server-update-routes.js +215 -0
  35. package/dist/server.d.ts +6 -4
  36. package/dist/server.js +350 -1313
  37. package/dist/session-logger.d.ts +32 -2
  38. package/dist/session-logger.js +145 -15
  39. package/dist/session-registry.d.ts +27 -0
  40. package/dist/session-registry.js +153 -0
  41. package/dist/session-transport.d.ts +31 -0
  42. package/dist/session-transport.js +82 -0
  43. package/dist/storage.d.ts +24 -6
  44. package/dist/storage.js +291 -44
  45. package/dist/structured-claude-adapter.d.ts +19 -0
  46. package/dist/structured-claude-adapter.js +117 -0
  47. package/dist/structured-codex-adapter.d.ts +3 -0
  48. package/dist/structured-codex-adapter.js +29 -0
  49. package/dist/structured-opencode-adapter.d.ts +11 -0
  50. package/dist/structured-opencode-adapter.js +115 -0
  51. package/dist/structured-provider-common.d.ts +11 -0
  52. package/dist/structured-provider-common.js +77 -0
  53. package/dist/structured-session-manager.d.ts +32 -35
  54. package/dist/structured-session-manager.js +551 -605
  55. package/dist/types.d.ts +10 -0
  56. package/dist/update-helper.js +5 -1
  57. package/dist/web-ui/content/scripts.js +32 -32
  58. package/dist/web-ui/embedded-assets.d.ts +1 -1
  59. package/dist/web-ui/embedded-assets.js +2 -2
  60. package/dist/ws-broadcast.d.ts +16 -1
  61. package/dist/ws-broadcast.js +124 -58
  62. package/package.json +2 -1
@@ -1,11 +1,23 @@
1
1
  import { SessionInputError } from "./process-manager.js";
2
- import { getDefaultModelForProvider, normalizeMode } from "./config.js";
2
+ import { getDefaultModelForProvider, isExecutionMode } from "./config.js";
3
3
  import { blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
4
- import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
4
+ import { toSessionDetailDTO, toSessionListItemDTO } from "./session-transport.js";
5
+ import { checkSessionWorktreeMergeabilityAsync, cleanupSessionWorktreeAsync, getWorktreeMergeErrorCode, mergeSessionWorktreeAsync, WorktreeMergeError, } from "./git-worktree.js";
5
6
  import { resolveSessionCwd } from "./session-cwd.js";
6
7
  import { resolveCommitAiContext } from "./session-ai-context.js";
7
- import { getGitStatus, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
8
+ import { getGitStatusAsync, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
8
9
  import { getErrorMessage } from "./error-utils.js";
10
+ import { isProviderSessionId } from "./resume-policy.js";
11
+ import { parseBoundedInteger } from "./request-limits.js";
12
+ import { asyncRoute } from "./express-async.js";
13
+ export function parseExecutionMode(value, fallback) {
14
+ if (value === undefined)
15
+ return fallback;
16
+ if (!isExecutionMode(value)) {
17
+ throw new Error(`无效执行模式: ${String(value)}`);
18
+ }
19
+ return value;
20
+ }
9
21
  export function parseSessionCreationOrigin(body) {
10
22
  const rawSource = body?.sessionSource;
11
23
  if (rawSource !== undefined && rawSource !== "interactive" && rawSource !== "automation" && rawSource !== "startup") {
@@ -115,9 +127,6 @@ function removeFromHiddenClaudeSessionIds(storage, ids) {
115
127
  saveHiddenClaudeSessionIds(storage, hidden);
116
128
  }
117
129
  }
118
- function getSessionById(processes, structured, id) {
119
- return structured.get(id) ?? processes.get(id);
120
- }
121
130
  /**
122
131
  * A Wand-managed session and its provider-native history represent the same
123
132
  * conversation. Delete both in one operation so the native history cannot
@@ -157,10 +166,26 @@ export function deleteSessionWithProviderHistory(processes, structured, storage,
157
166
  // Keep a tombstone as a fallback for permission errors or process tail writes.
158
167
  addToHiddenClaudeSessionIds(storage, [providerSessionId]);
159
168
  }
160
- /** Lightweight session list — omits output and messages to reduce payload. */
161
- function listAllSessionsSlim(processes, structured) {
162
- return [...structured.listSlim(), ...processes.listSlim()]
163
- .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
169
+ /**
170
+ * Provider history is scanned by ProcessManager, but structured sessions live
171
+ * in StructuredSessionManager. Annotate against the combined session list so a
172
+ * structured conversation is not also exposed as a recoverable native history
173
+ * entry. Return copies for matches because ProcessManager caches scan results.
174
+ */
175
+ export function markManagedProviderHistory(history, sessions, provider) {
176
+ const managedIds = new Set();
177
+ for (const session of sessions) {
178
+ const sessionProvider = session.provider
179
+ ?? session.structuredState?.provider
180
+ ?? (/^codex\b/i.test(session.command.trim()) ? "codex" : "claude");
181
+ const providerSessionId = session.claudeSessionId?.trim();
182
+ if (sessionProvider === provider && providerSessionId) {
183
+ managedIds.add(providerSessionId);
184
+ }
185
+ }
186
+ return history.map((entry) => (entry.managedByWand || !managedIds.has(entry.claudeSessionId)
187
+ ? entry
188
+ : { ...entry, managedByWand: true }));
164
189
  }
165
190
  function requireWorktreeSession(snapshot) {
166
191
  if (!snapshot) {
@@ -179,15 +204,14 @@ function buildWorktreeMergeInfo(current, info) {
179
204
  conflict: info?.conflict,
180
205
  };
181
206
  }
182
- function saveWorktreeMergeState(storage, current, status, info) {
207
+ function saveWorktreeMergeState(sessions, current, status, info) {
183
208
  const mergedInfo = buildWorktreeMergeInfo(current, info);
184
209
  const updated = {
185
210
  ...current,
186
211
  worktreeMergeStatus: status,
187
212
  worktreeMergeInfo: mergedInfo,
188
213
  };
189
- storage.saveSessionMetadata(updated);
190
- return updated;
214
+ return sessions.updateWorktreeState(current.id, status, mergedInfo) ?? updated;
191
215
  }
192
216
  function getWorktreeMergeResponseStatus(error) {
193
217
  return getWorktreeMergeErrorCode(error) === "WORKTREE_MERGE_CONFLICT" ? 409 : 400;
@@ -206,9 +230,6 @@ function getWorktreeMergePayload(error, fallback) {
206
230
  result: null,
207
231
  };
208
232
  }
209
- function getLatestSessionSnapshot(processes, structured, storage, id) {
210
- return getSessionById(processes, structured, id) ?? storage.getSession(id);
211
- }
212
233
  function buildCodexResumeCommand(command, threadId) {
213
234
  const trimmed = command.trim();
214
235
  const withoutExistingResume = trimmed
@@ -244,7 +265,7 @@ function startResumedPtySession(processes, existingSession, sessionId, defaultMo
244
265
  throw new Error("对应的 Codex 历史会话不存在,无法恢复。");
245
266
  }
246
267
  }
247
- const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
268
+ const newMode = parseExecutionMode(body.mode, parseExecutionMode(existingSession.mode, defaultMode));
248
269
  const resumeCommand = provider === "codex"
249
270
  ? buildCodexResumeCommand(command, resumeSessionId)
250
271
  : `${command} --resume ${resumeSessionId}`;
@@ -288,12 +309,19 @@ function canMergeSession(snapshot) {
288
309
  function isMergeActionAllowed(snapshot) {
289
310
  return snapshot.status !== "running";
290
311
  }
291
- export function registerSessionRoutes(app, processes, structured, storage, defaultMode, config, onSessionCreated) {
312
+ export function registerSessionRoutes(app, processes, structured, storage, defaultMode, config, sessions, onSessionCreated) {
313
+ const sessionResponseDTO = (snapshot) => {
314
+ const windowed = windowMessagesForTransport(snapshot.messages ?? [], config.cardDefaults ?? {});
315
+ return toSessionDetailDTO(snapshot, {
316
+ messages: windowed.messages,
317
+ messageOffset: windowed.messageOffset,
318
+ messageTotal: windowed.messageTotal,
319
+ });
320
+ };
292
321
  app.get("/api/sessions", (_req, res) => {
293
- const all = listAllSessionsSlim(processes, structured);
294
- res.json(all);
322
+ res.json(sessions.listSlim().map(toSessionListItemDTO));
295
323
  });
296
- app.post("/api/structured-sessions", async (req, res) => {
324
+ app.post("/api/structured-sessions", asyncRoute(async (req, res) => {
297
325
  const body = req.body;
298
326
  try {
299
327
  if (body.provider && body.provider !== "claude" && body.provider !== "codex" && body.provider !== "opencode") {
@@ -305,9 +333,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
305
333
  const origin = parseSessionCreationOrigin(body);
306
334
  const snapshot = structured.createSession({
307
335
  cwd: resolveSessionCwd(body.cwd, config.defaultCwd),
308
- mode: normalizeMode(body.mode, defaultMode),
336
+ mode: parseExecutionMode(body.mode, defaultMode),
309
337
  provider,
310
- runner: body.runner ?? (provider === "codex" ? "codex-cli-exec" : provider === "opencode" ? "opencode-cli-run" : "claude-cli-print"),
338
+ // Omit runner to let StructuredSessionManager apply the configured
339
+ // Claude default; explicit values are validated against the provider.
340
+ runner: body.runner,
311
341
  worktreeEnabled: body.worktreeEnabled === true,
312
342
  model: rawModel || getDefaultModelForProvider(config, provider) || undefined,
313
343
  thinkingEffort: typeof body.thinkingEffort === "string"
@@ -322,29 +352,22 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
322
352
  res.status(201).json(finished);
323
353
  return;
324
354
  }
325
- res.status(201).json(snapshot);
355
+ res.status(201).json(sessionResponseDTO(snapshot));
326
356
  }
327
357
  catch (error) {
328
358
  res.status(400).json({ error: getErrorMessage(error, "无法启动结构化会话。") });
329
359
  }
330
- });
360
+ }));
331
361
  app.post("/api/sessions/:id/model", (req, res) => {
332
362
  const body = req.body;
333
363
  const rawModel = typeof body?.model === "string" ? body.model.trim() : null;
334
364
  const id = req.params.id;
335
365
  try {
336
- const structuredSnapshot = structured.get(id);
337
- if (structuredSnapshot) {
338
- const updated = structured.setSessionModel(id, rawModel);
339
- res.json(updated);
340
- return;
341
- }
342
- const ptySnapshot = processes.get(id);
343
- if (!ptySnapshot) {
366
+ const updated = sessions.setSessionModel(id, rawModel);
367
+ if (!updated) {
344
368
  res.status(404).json({ error: "未找到该会话。" });
345
369
  return;
346
370
  }
347
- const updated = processes.setSessionModel(id, rawModel);
348
371
  res.json(updated);
349
372
  }
350
373
  catch (error) {
@@ -358,16 +381,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
358
381
  const raw = typeof body?.thinkingEffort === "string" ? body.thinkingEffort : null;
359
382
  const id = req.params.id;
360
383
  try {
361
- if (structured.get(id)) {
362
- const updated = structured.setSessionThinkingEffort(id, raw);
363
- res.json(updated);
364
- return;
365
- }
366
- if (!processes.get(id)) {
384
+ const updated = sessions.setSessionThinkingEffort(id, raw);
385
+ if (!updated) {
367
386
  res.status(404).json({ error: "未找到该会话。" });
368
387
  return;
369
388
  }
370
- const updated = processes.setSessionThinkingEffort(id, raw);
371
389
  res.json(updated);
372
390
  }
373
391
  catch (error) {
@@ -384,23 +402,20 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
384
402
  res.status(400).json({ error: "缺少 mode。" });
385
403
  return;
386
404
  }
387
- const mode = normalizeMode(raw, "managed");
405
+ if (!isExecutionMode(raw)) {
406
+ res.status(400).json({ error: `无效执行模式: ${raw}` });
407
+ return;
408
+ }
409
+ const mode = raw;
388
410
  const id = req.params.id;
389
411
  try {
390
- const structuredSnapshot = structured.get(id);
391
- if (structuredSnapshot) {
392
- const provider = structuredSnapshot.provider ?? "claude";
393
- const effective = provider === "codex" ? "full-access" : mode;
394
- res.json(structured.setSessionMode(id, effective));
395
- return;
396
- }
397
- const ptySnapshot = processes.get(id);
398
- if (!ptySnapshot) {
412
+ const snapshot = sessions.get(id);
413
+ if (!snapshot) {
399
414
  res.status(404).json({ error: "未找到该会话。" });
400
415
  return;
401
416
  }
402
- const effective = (ptySnapshot.provider ?? "claude") === "codex" ? "full-access" : mode;
403
- res.json(processes.setSessionMode(id, effective));
417
+ const effective = (snapshot.provider ?? "claude") === "codex" ? "full-access" : mode;
418
+ res.json(sessions.setSessionMode(id, effective));
404
419
  }
405
420
  catch (error) {
406
421
  res.status(400).json({ error: getErrorMessage(error, "切换模式失败。") });
@@ -414,7 +429,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
414
429
  }
415
430
  res.json({ id: snapshot.id, messages: snapshot.messages ?? [] });
416
431
  });
417
- app.post("/api/structured-sessions/:id/messages", async (req, res) => {
432
+ app.post("/api/structured-sessions/:id/messages", asyncRoute(async (req, res) => {
418
433
  const input = String(req.body?.input ?? "");
419
434
  const interrupt = !!req.body?.interrupt;
420
435
  // preserveQueue: 仅在 interrupt 路径有意义。排队条「立即」会带这个 flag,
@@ -423,7 +438,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
423
438
  const idempotencyKey = typeof req.body?.idempotencyKey === "string" ? req.body.idempotencyKey : undefined;
424
439
  try {
425
440
  const snapshot = await structured.sendMessage(req.params.id, input, { interrupt, preserveQueue, idempotencyKey });
426
- res.json(snapshot);
441
+ res.json(sessionResponseDTO(snapshot));
427
442
  }
428
443
  catch (error) {
429
444
  const errorCode = error?.code;
@@ -433,7 +448,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
433
448
  errorCode,
434
449
  });
435
450
  }
436
- });
451
+ }));
437
452
  // ── Structured queued-messages management ──
438
453
  // 这些端点构成"排队消息条"的后端操作面:reorder、立即发送、单条删除、全部清空。
439
454
  // 全部走乐观更新模型,失败时前端会回滚到上一次 WS 推送的 queuedMessages。
@@ -445,7 +460,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
445
460
  }
446
461
  try {
447
462
  const snapshot = structured.reorderQueuedMessages(req.params.id, rawOrder.map((v) => Number(v)));
448
- res.json(snapshot);
463
+ res.json(sessionResponseDTO(snapshot));
449
464
  }
450
465
  catch (error) {
451
466
  res.status(400).json({ error: getErrorMessage(error, "无法调整排队顺序。") });
@@ -459,13 +474,13 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
459
474
  }
460
475
  try {
461
476
  const snapshot = structured.deleteQueuedMessage(req.params.id, index);
462
- res.json(snapshot);
477
+ res.json(sessionResponseDTO(snapshot));
463
478
  }
464
479
  catch (error) {
465
480
  res.status(400).json({ error: getErrorMessage(error, "无法删除排队消息。") });
466
481
  }
467
482
  });
468
- app.post("/api/structured-sessions/:id/queued/:index/promote", async (req, res) => {
483
+ app.post("/api/structured-sessions/:id/queued/:index/promote", asyncRoute(async (req, res) => {
469
484
  const index = Number(req.params.index);
470
485
  if (!Number.isInteger(index)) {
471
486
  res.status(400).json({ error: "下标无效。" });
@@ -475,7 +490,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
475
490
  const idempotencyKey = typeof req.body?.idempotencyKey === "string" ? req.body.idempotencyKey : undefined;
476
491
  try {
477
492
  const snapshot = await structured.promoteQueuedMessage(req.params.id, index, expectedText, idempotencyKey);
478
- res.json(snapshot);
493
+ res.json(sessionResponseDTO(snapshot));
479
494
  }
480
495
  catch (error) {
481
496
  const errorCode = error?.code;
@@ -485,11 +500,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
485
500
  errorCode,
486
501
  });
487
502
  }
488
- });
503
+ }));
489
504
  app.delete("/api/structured-sessions/:id/queued", (req, res) => {
490
505
  try {
491
506
  const snapshot = structured.clearQueuedMessages(req.params.id);
492
- res.json(snapshot);
507
+ res.json(sessionResponseDTO(snapshot));
493
508
  }
494
509
  catch (error) {
495
510
  res.status(400).json({ error: getErrorMessage(error, "无法清空排队消息。") });
@@ -497,7 +512,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
497
512
  });
498
513
  // ── Tool content lazy-load endpoint ──
499
514
  app.get("/api/sessions/:id/tool-content/:toolUseId", (req, res) => {
500
- const snapshot = getSessionById(processes, structured, req.params.id);
515
+ const snapshot = sessions.get(req.params.id);
501
516
  if (!snapshot) {
502
517
  res.status(404).json({ error: "未找到该会话。" });
503
518
  return;
@@ -518,25 +533,25 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
518
533
  }
519
534
  res.status(404).json({ error: "未找到该工具结果。" });
520
535
  });
521
- app.post("/api/sessions/:id/worktree/merge/check", (req, res) => {
536
+ app.post("/api/sessions/:id/worktree/merge/check", asyncRoute(async (req, res) => {
522
537
  try {
523
- const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
538
+ const current = requireWorktreeSession(sessions.getLatest(req.params.id));
524
539
  if (!isMergeActionAllowed(current)) {
525
540
  res.status(409).json({ error: "会话仍在运行,请结束后再合并。", errorCode: "SESSION_STILL_RUNNING" });
526
541
  return;
527
542
  }
528
- const checking = saveWorktreeMergeState(storage, current, "checking", {
543
+ const checking = saveWorktreeMergeState(sessions, current, "checking", {
529
544
  ...(current.worktreeMergeInfo ?? null),
530
545
  targetBranch: current.worktreeMergeInfo?.targetBranch,
531
546
  lastError: undefined,
532
547
  conflict: false,
533
548
  });
534
- const result = checkSessionWorktreeMergeability({
549
+ const result = await checkSessionWorktreeMergeabilityAsync({
535
550
  worktree: checking.worktree,
536
551
  targetBranch: current.worktreeMergeInfo?.targetBranch,
537
552
  });
538
553
  const nextStatus = result.ok ? "ready" : "failed";
539
- const updated = saveWorktreeMergeState(storage, checking, nextStatus, {
554
+ const updated = saveWorktreeMergeState(sessions, checking, nextStatus, {
540
555
  targetBranch: result.targetBranch,
541
556
  conflict: result.hasConflicts,
542
557
  lastError: result.ok ? undefined : result.reason,
@@ -546,24 +561,24 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
546
561
  catch (error) {
547
562
  res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法检查 worktree 合并状态。"));
548
563
  }
549
- });
550
- app.post("/api/sessions/:id/worktree/merge", (req, res) => {
564
+ }));
565
+ app.post("/api/sessions/:id/worktree/merge", asyncRoute(async (req, res) => {
551
566
  try {
552
- const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
567
+ const current = requireWorktreeSession(sessions.getLatest(req.params.id));
553
568
  if (!isMergeActionAllowed(current)) {
554
569
  res.status(409).json({ error: "会话仍在运行,请结束后再合并。", errorCode: "SESSION_STILL_RUNNING" });
555
570
  return;
556
571
  }
557
- const merging = saveWorktreeMergeState(storage, current, "merging", {
572
+ const merging = saveWorktreeMergeState(sessions, current, "merging", {
558
573
  ...(current.worktreeMergeInfo ?? null),
559
574
  lastError: undefined,
560
575
  conflict: false,
561
576
  });
562
- const result = mergeSessionWorktree({
577
+ const result = await mergeSessionWorktreeAsync({
563
578
  worktree: merging.worktree,
564
579
  targetBranch: current.worktreeMergeInfo?.targetBranch,
565
580
  });
566
- const updated = saveWorktreeMergeState(storage, merging, "merged", {
581
+ const updated = saveWorktreeMergeState(sessions, merging, "merged", {
567
582
  targetBranch: result.targetBranch,
568
583
  mergedAt: result.mergedAt,
569
584
  mergeCommit: result.mergeCommit,
@@ -574,10 +589,10 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
574
589
  res.json({ session: updated, result });
575
590
  }
576
591
  catch (error) {
577
- const current = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
592
+ const current = sessions.getLatest(req.params.id);
578
593
  if (current && canMergeSession(current)) {
579
594
  const payload = getWorktreeMergePayload(error, "无法合并 worktree。");
580
- saveWorktreeMergeState(storage, current, "failed", {
595
+ saveWorktreeMergeState(sessions, current, "failed", {
581
596
  ...(current.worktreeMergeInfo ?? null),
582
597
  targetBranch: payload.result?.targetBranch ?? current.worktreeMergeInfo?.targetBranch,
583
598
  mergedAt: payload.result?.mergedAt,
@@ -589,9 +604,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
589
604
  }
590
605
  res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法合并 worktree。"));
591
606
  }
592
- });
593
- app.get("/api/sessions/:id/git-status", (req, res) => {
594
- const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
607
+ }));
608
+ app.get("/api/sessions/:id/git-status", asyncRoute(async (req, res) => {
609
+ const snapshot = sessions.getLatest(req.params.id);
595
610
  if (!snapshot) {
596
611
  res.status(404).json({ error: "未找到该会话。" });
597
612
  return;
@@ -601,14 +616,14 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
601
616
  return;
602
617
  }
603
618
  try {
604
- res.json(getGitStatus(snapshot.cwd));
619
+ res.json(await getGitStatusAsync(snapshot.cwd));
605
620
  }
606
621
  catch (error) {
607
622
  res.json({ isGit: false, error: getErrorMessage(error, "无法读取 git 状态。") });
608
623
  }
609
- });
610
- app.post("/api/sessions/:id/quick-commit", async (req, res) => {
611
- const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
624
+ }));
625
+ app.post("/api/sessions/:id/quick-commit", asyncRoute(async (req, res) => {
626
+ const snapshot = sessions.getLatest(req.params.id);
612
627
  if (!snapshot) {
613
628
  res.status(404).json({ error: "未找到该会话。" });
614
629
  return;
@@ -641,9 +656,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
641
656
  }
642
657
  res.status(400).json({ error: getErrorMessage(error, "快捷提交失败。") });
643
658
  }
644
- });
645
- app.post("/api/sessions/:id/generate-commit-message", async (req, res) => {
646
- const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
659
+ }));
660
+ app.post("/api/sessions/:id/generate-commit-message", asyncRoute(async (req, res) => {
661
+ const snapshot = sessions.getLatest(req.params.id);
647
662
  if (!snapshot) {
648
663
  res.status(404).json({ error: "未找到该会话。" });
649
664
  return;
@@ -666,9 +681,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
666
681
  }
667
682
  res.status(400).json({ error: getErrorMessage(error, "生成 commit message 失败。") });
668
683
  }
669
- });
670
- app.post("/api/sessions/:id/git/tag-head", async (req, res) => {
671
- const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
684
+ }));
685
+ app.post("/api/sessions/:id/git/tag-head", asyncRoute(async (req, res) => {
686
+ const snapshot = sessions.getLatest(req.params.id);
672
687
  if (!snapshot) {
673
688
  res.status(404).json({ error: "未找到该会话。" });
674
689
  return;
@@ -698,9 +713,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
698
713
  }
699
714
  res.status(400).json({ error: getErrorMessage(error, "打 tag 失败。") });
700
715
  }
701
- });
702
- app.post("/api/sessions/:id/git/push", async (req, res) => {
703
- const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
716
+ }));
717
+ app.post("/api/sessions/:id/git/push", asyncRoute(async (req, res) => {
718
+ const snapshot = sessions.getLatest(req.params.id);
704
719
  if (!snapshot) {
705
720
  res.status(404).json({ error: "未找到该会话。" });
706
721
  return;
@@ -727,16 +742,16 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
727
742
  }
728
743
  res.status(400).json({ error: getErrorMessage(error, "推送失败。") });
729
744
  }
730
- });
731
- app.post("/api/sessions/:id/worktree/cleanup", (req, res) => {
745
+ }));
746
+ app.post("/api/sessions/:id/worktree/cleanup", asyncRoute(async (req, res) => {
732
747
  try {
733
- const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
748
+ const current = requireWorktreeSession(sessions.getLatest(req.params.id));
734
749
  if (current.worktreeMergeStatus !== "merged" || current.worktreeMergeInfo?.cleanupDone !== false) {
735
750
  res.status(400).json({ error: "当前 worktree 无需补偿清理。", errorCode: "WORKTREE_CLEANUP_NOT_NEEDED" });
736
751
  return;
737
752
  }
738
- cleanupSessionWorktree({ worktree: current.worktree });
739
- const updated = saveWorktreeMergeState(storage, current, "merged", {
753
+ await cleanupSessionWorktreeAsync({ worktree: current.worktree });
754
+ const updated = saveWorktreeMergeState(sessions, current, "merged", {
740
755
  ...(current.worktreeMergeInfo ?? null),
741
756
  cleanupDone: true,
742
757
  lastError: undefined,
@@ -747,7 +762,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
747
762
  catch (error) {
748
763
  res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法清理 worktree。"));
749
764
  }
750
- });
765
+ }));
751
766
  app.post("/api/sessions/batch-delete", (req, res) => {
752
767
  const sessionIds = Array.isArray(req.body?.sessionIds)
753
768
  ? req.body.sessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
@@ -760,7 +775,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
760
775
  const failed = [];
761
776
  for (const sessionId of sessionIds) {
762
777
  try {
763
- deleteSessionWithProviderHistory(processes, structured, storage, sessionId);
778
+ sessions.deleteWithProviderHistory(sessionId);
764
779
  deleted += 1;
765
780
  }
766
781
  catch {
@@ -774,7 +789,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
774
789
  res.json({ ok: true, deleted, failed });
775
790
  });
776
791
  app.get("/api/sessions/:id", (req, res) => {
777
- const snapshot = getSessionById(processes, structured, req.params.id);
792
+ const snapshot = sessions.get(req.params.id);
778
793
  if (!snapshot) {
779
794
  res.status(404).json({ error: "未找到该会话,可能已被删除。" });
780
795
  return;
@@ -785,38 +800,37 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
785
800
  if (req.query.format === "chat") {
786
801
  // 客户端带 blockBudget(iOS)走块级窗口:只回最近 N 个块(必要时切掉最旧 turn 的头部),
787
802
  // 根治「单条 turn 上百块/1MB」的长任务打开慢。Web/Android 不带该参数,走原 turn 级窗口。
788
- const rawBudget = parseInt(String(req.query.blockBudget ?? ""), 10);
789
- if (Number.isFinite(rawBudget) && rawBudget > 0) {
790
- const windowed = blockWindowMessagesForTransport(snapshot.messages ?? [], config.cardDefaults ?? {}, rawBudget);
791
- res.json({
792
- ...snapshot,
803
+ const rawBudget = req.query.blockBudget;
804
+ if (typeof rawBudget === "string" && /^\d+$/.test(rawBudget) && Number(rawBudget) > 0) {
805
+ const blockBudget = parseBoundedInteger(rawBudget, 1, 1, 2_000);
806
+ const windowed = blockWindowMessagesForTransport(snapshot.messages ?? [], config.cardDefaults ?? {}, blockBudget);
807
+ res.json(toSessionDetailDTO(snapshot, {
793
808
  output: transcriptOutput,
794
809
  messages: windowed.messages,
795
810
  messageOffset: windowed.messageOffset,
796
811
  messageTotal: windowed.messageTotal,
797
812
  leadingBlockOffset: windowed.leadingBlockOffset,
798
813
  leadingBlockTotal: windowed.leadingBlockTotal,
799
- });
814
+ }));
800
815
  return;
801
816
  }
802
817
  // 与 WS init 对齐:只回最近一窗 turn + offset/total,更早的走 /messages 翻页。
803
818
  const windowed = windowMessagesForTransport(snapshot.messages ?? [], config.cardDefaults ?? {});
804
- res.json({
805
- ...snapshot,
819
+ res.json(toSessionDetailDTO(snapshot, {
806
820
  output: transcriptOutput,
807
821
  messages: windowed.messages,
808
822
  messageOffset: windowed.messageOffset,
809
823
  messageTotal: windowed.messageTotal,
810
- });
824
+ }));
811
825
  }
812
826
  else {
813
- res.json({ ...snapshot, output: transcriptOutput });
827
+ res.json(toSessionDetailDTO(snapshot, { output: transcriptOutput }));
814
828
  }
815
829
  });
816
830
  // 历史消息分页拉取:客户端滚动到顶时按绝对下标往前翻。
817
831
  // 返回 messages = 完整历史的 [offset, offset+limit)(已做 transport 截断)+ total。
818
832
  app.get("/api/sessions/:id/messages", (req, res) => {
819
- const snapshot = getSessionById(processes, structured, req.params.id);
833
+ const snapshot = sessions.get(req.params.id);
820
834
  if (!snapshot) {
821
835
  res.status(404).json({ error: "未找到该会话,可能已被删除。" });
822
836
  return;
@@ -878,8 +892,8 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
878
892
  const requestedOrigin = body.sessionSource !== undefined || body.automationId !== undefined
879
893
  ? parseSessionCreationOrigin(body)
880
894
  : null;
881
- if (!claudeSessionId) {
882
- res.status(400).json({ error: "Claude 会话 ID 不能为空。" });
895
+ if (!isProviderSessionId(claudeSessionId)) {
896
+ res.status(400).json({ error: "Claude 会话 ID 必须是有效的 UUID。" });
883
897
  return;
884
898
  }
885
899
  const existingSession = storage.getLatestSessionByClaudeSessionId(claudeSessionId);
@@ -901,8 +915,10 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
901
915
  res.status(400).json({ error: "对应的 Claude 历史会话文件不存在,无法恢复。" });
902
916
  return;
903
917
  }
904
- const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
905
- const resumeCommand = `${command} --resume ${claudeSessionId}`;
918
+ const newMode = parseExecutionMode(body.mode, parseExecutionMode(existingSession.mode, defaultMode));
919
+ // Do not reuse the persisted shell command here. It may contain flags or
920
+ // shell operators and must never become part of a resume command.
921
+ const resumeCommand = `claude --resume ${claudeSessionId}`;
906
922
  const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
907
923
  const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
908
924
  const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, {
@@ -911,7 +927,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
911
927
  rows: reqRows,
912
928
  ...(requestedOrigin ?? {}),
913
929
  });
914
- res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
930
+ res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...sessionResponseDTO(newSnapshot) });
915
931
  }
916
932
  else {
917
933
  const cwd = body.cwd?.trim();
@@ -919,7 +935,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
919
935
  res.status(400).json({ error: "未找到对应的会话记录,请提供工作目录 (cwd)。" });
920
936
  return;
921
937
  }
922
- const newMode = normalizeMode(body.mode, defaultMode);
938
+ const newMode = parseExecutionMode(body.mode, defaultMode);
923
939
  const resumeCommand = `claude --resume ${claudeSessionId}`;
924
940
  const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
925
941
  const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
@@ -928,14 +944,14 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
928
944
  rows: reqRows,
929
945
  ...(requestedOrigin ?? {}),
930
946
  });
931
- res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
947
+ res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...sessionResponseDTO(newSnapshot) });
932
948
  }
933
949
  }
934
950
  catch (error) {
935
951
  res.status(400).json({ error: getErrorMessage(error, "无法按 Claude 会话 ID 恢复会话。") });
936
952
  }
937
953
  });
938
- app.post("/api/sessions/:id/input", async (req, res) => {
954
+ app.post("/api/sessions/:id/input", asyncRoute(async (req, res) => {
939
955
  const body = req.body;
940
956
  const sessionId = req.params.id;
941
957
  const input = body.input ?? "";
@@ -944,18 +960,18 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
944
960
  try {
945
961
  if (structured.get(sessionId)) {
946
962
  const snapshot = await structured.sendMessage(sessionId, input);
947
- res.json(snapshot);
963
+ res.json(sessionResponseDTO(snapshot));
948
964
  return;
949
965
  }
950
966
  const existingSession = processes.get(sessionId) || storage.getSession(sessionId);
951
967
  const autoResumeInput = getAutoResumeInitialInput(existingSession, input, view, shortcutKey);
952
968
  if (autoResumeInput !== null && canAutoResumePtyForInput(existingSession, autoResumeInput)) {
953
969
  const snapshot = startResumedPtySession(processes, existingSession, sessionId, defaultMode, {}, autoResumeInput);
954
- res.json(snapshot);
970
+ res.json(sessionResponseDTO(snapshot));
955
971
  return;
956
972
  }
957
973
  const snapshot = processes.sendInput(sessionId, input, view, shortcutKey);
958
- res.json(snapshot);
974
+ res.json(sessionResponseDTO(snapshot));
959
975
  }
960
976
  catch (error) {
961
977
  const response = getInputErrorResponse(error, sessionId);
@@ -966,13 +982,13 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
966
982
  });
967
983
  res.status(response.statusCode).json(response.payload);
968
984
  }
969
- });
970
- app.post("/api/codex-sessions/:threadId/resume", async (req, res) => {
985
+ }));
986
+ app.post("/api/codex-sessions/:threadId/resume", asyncRoute(async (req, res) => {
971
987
  const threadId = String(req.params.threadId || "").trim();
972
988
  const body = req.body;
973
989
  try {
974
- if (!threadId) {
975
- res.status(400).json({ error: "Codex 会话 ID 不能为空。" });
990
+ if (!isProviderSessionId(threadId)) {
991
+ res.status(400).json({ error: "Codex 会话 ID 必须是有效的 UUID。" });
976
992
  return;
977
993
  }
978
994
  const history = processes.listCodexHistorySessions().find((s) => s.claudeSessionId === threadId);
@@ -985,7 +1001,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
985
1001
  res.status(400).json({ error: "无法确定工作目录 (cwd),无法恢复。" });
986
1002
  return;
987
1003
  }
988
- const newMode = normalizeMode(body.mode, defaultMode);
1004
+ const newMode = parseExecutionMode(body.mode, defaultMode);
989
1005
  const origin = parseSessionCreationOrigin(body);
990
1006
  const snapshot = structured.createSession({
991
1007
  cwd,
@@ -997,21 +1013,21 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
997
1013
  ...origin,
998
1014
  });
999
1015
  onSessionCreated?.(cwd);
1000
- res.status(201).json({ resumedClaudeSessionId: threadId, ...snapshot });
1016
+ res.status(201).json({ resumedClaudeSessionId: threadId, ...sessionResponseDTO(snapshot) });
1001
1017
  }
1002
1018
  catch (error) {
1003
1019
  res.status(400).json({ error: getErrorMessage(error, "无法按 Codex 会话 ID 恢复会话。") });
1004
1020
  }
1005
- });
1021
+ }));
1006
1022
  app.post("/api/sessions/:id/resize", (req, res) => {
1007
1023
  const body = req.body;
1008
1024
  try {
1009
- if (structured.get(req.params.id)) {
1025
+ if (sessions.ownerOf(req.params.id) === "structured") {
1010
1026
  res.status(400).json({ error: "结构化会话不支持调整终端大小。" });
1011
1027
  return;
1012
1028
  }
1013
1029
  const snapshot = processes.resize(req.params.id, body.cols ?? 0, body.rows ?? 0);
1014
- res.json(snapshot);
1030
+ res.json(sessionResponseDTO(snapshot));
1015
1031
  }
1016
1032
  catch (error) {
1017
1033
  res.status(400).json({ error: getErrorMessage(error, "无法调整终端大小。") });
@@ -1019,11 +1035,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1019
1035
  });
1020
1036
  app.post("/api/sessions/:id/approve-permission", (req, res) => {
1021
1037
  try {
1022
- if (structured.get(req.params.id)) {
1038
+ if (sessions.ownerOf(req.params.id) === "structured") {
1023
1039
  res.status(400).json({ error: "结构化会话不需要终端权限操作。" });
1024
1040
  return;
1025
1041
  }
1026
- const snapshot = processes.get(req.params.id);
1042
+ const snapshot = sessions.get(req.params.id);
1027
1043
  if (snapshot?.provider === "codex") {
1028
1044
  res.status(400).json({ error: "Codex provider 不支持权限批准操作。" });
1029
1045
  return;
@@ -1036,11 +1052,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1036
1052
  });
1037
1053
  app.post("/api/sessions/:id/deny-permission", (req, res) => {
1038
1054
  try {
1039
- if (structured.get(req.params.id)) {
1055
+ if (sessions.ownerOf(req.params.id) === "structured") {
1040
1056
  res.status(400).json({ error: "结构化会话不需要终端权限操作。" });
1041
1057
  return;
1042
1058
  }
1043
- const snapshot = processes.get(req.params.id);
1059
+ const snapshot = sessions.get(req.params.id);
1044
1060
  if (snapshot?.provider === "codex") {
1045
1061
  res.status(400).json({ error: "Codex provider 不支持权限拒绝操作。" });
1046
1062
  return;
@@ -1053,11 +1069,11 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1053
1069
  });
1054
1070
  app.post("/api/sessions/:id/toggle-auto-approve", (req, res) => {
1055
1071
  try {
1056
- if (structured.get(req.params.id)) {
1072
+ if (sessions.ownerOf(req.params.id) === "structured") {
1057
1073
  res.status(400).json({ error: "结构化会话不需要切换终端自动批准。" });
1058
1074
  return;
1059
1075
  }
1060
- const snapshot = processes.get(req.params.id);
1076
+ const snapshot = sessions.get(req.params.id);
1061
1077
  if (snapshot?.provider === "codex") {
1062
1078
  res.status(400).json({ error: "Codex provider 不支持自动批准切换。" });
1063
1079
  return;
@@ -1072,11 +1088,16 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1072
1088
  try {
1073
1089
  const { requestId } = req.params;
1074
1090
  const body = req.body;
1075
- if (structured.get(req.params.id)) {
1076
- res.json(structured.resolveEscalation(req.params.id, requestId, body.resolution));
1091
+ const resolution = body?.resolution;
1092
+ if (resolution !== "approve_once" && resolution !== "approve_turn" && resolution !== "deny") {
1093
+ res.status(400).json({ error: "resolution 必须是 approve_once、approve_turn 或 deny。" });
1094
+ return;
1095
+ }
1096
+ if (sessions.ownerOf(req.params.id) === "structured") {
1097
+ res.json(structured.resolveEscalation(req.params.id, requestId, resolution));
1077
1098
  return;
1078
1099
  }
1079
- res.json(processes.resolveEscalation(req.params.id, requestId, body.resolution));
1100
+ res.json(processes.resolveEscalation(req.params.id, requestId, resolution));
1080
1101
  }
1081
1102
  catch (error) {
1082
1103
  res.status(400).json({ error: getErrorMessage(error, "无法处理该授权请求。") });
@@ -1084,7 +1105,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1084
1105
  });
1085
1106
  app.post("/api/sessions/:id/stop", (req, res) => {
1086
1107
  try {
1087
- if (structured.get(req.params.id)) {
1108
+ if (sessions.ownerOf(req.params.id) === "structured") {
1088
1109
  res.json(structured.stop(req.params.id));
1089
1110
  return;
1090
1111
  }
@@ -1096,7 +1117,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1096
1117
  });
1097
1118
  app.delete("/api/sessions/:id", (req, res) => {
1098
1119
  try {
1099
- deleteSessionWithProviderHistory(processes, structured, storage, req.params.id);
1120
+ sessions.deleteWithProviderHistory(req.params.id);
1100
1121
  res.json({ ok: true });
1101
1122
  }
1102
1123
  catch (error) {
@@ -1104,14 +1125,14 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1104
1125
  }
1105
1126
  });
1106
1127
  }
1107
- export function registerClaudeHistoryRoutes(app, processes, storage) {
1128
+ export function registerClaudeHistoryRoutes(app, processes, structured, storage, sessionRegistry) {
1108
1129
  app.get("/api/claude-history", (_req, res) => {
1109
1130
  try {
1110
- const sessions = processes.listClaudeHistorySessions();
1131
+ const history = markManagedProviderHistory(processes.listClaudeHistorySessions(), sessionRegistry.listSlim(), "claude");
1111
1132
  const hidden = getHiddenClaudeSessionIds(storage);
1112
1133
  const filtered = hidden.size > 0
1113
- ? sessions.filter((s) => !s.claudeSessionId || !hidden.has(s.claudeSessionId))
1114
- : sessions;
1134
+ ? history.filter((s) => !s.claudeSessionId || !hidden.has(s.claudeSessionId))
1135
+ : history;
1115
1136
  res.json(filtered);
1116
1137
  }
1117
1138
  catch (error) {
@@ -1212,11 +1233,11 @@ export function registerClaudeHistoryRoutes(app, processes, storage) {
1212
1233
  // hidden 集合与 claude 共用(id 全局唯一,不会冲突)。
1213
1234
  app.get("/api/codex-history", (_req, res) => {
1214
1235
  try {
1215
- const sessions = processes.listCodexHistorySessions();
1236
+ const history = markManagedProviderHistory(processes.listCodexHistorySessions(), sessionRegistry.listSlim(), "codex");
1216
1237
  const hidden = getHiddenClaudeSessionIds(storage);
1217
1238
  const filtered = hidden.size > 0
1218
- ? sessions.filter((s) => !hidden.has(s.claudeSessionId))
1219
- : sessions;
1239
+ ? history.filter((s) => !hidden.has(s.claudeSessionId))
1240
+ : history;
1220
1241
  res.json(filtered);
1221
1242
  }
1222
1243
  catch (error) {