@cjhyy/code-shell-core 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -16,7 +16,7 @@
16
16
  import { Methods, ErrorCodes, createResponse, createErrorResponse, createNotification, isRequest, } from "./types.js";
17
17
  import { diskDefaultsFrom } from "../engine/engine.js";
18
18
  import { isProtectedSettingKey } from "../settings/manager.js";
19
- import { setInteractiveApprovalFn } from "../tool-system/permission.js";
19
+ import { getApprovalRouter, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
20
20
  import { getArenaStatus } from "../tool-system/builtin/arena.js";
21
21
  import { agentNotificationBus, notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
22
22
  import { backgroundShellManager } from "../runtime/background-shell.js";
@@ -24,9 +24,32 @@ import { backgroundJobRegistry } from "../tool-system/builtin/background-jobs.js
24
24
  import { listBackgroundWorkForUI } from "../tool-system/builtin/background-work.js";
25
25
  import { logger } from "../logging/logger.js";
26
26
  import { nanoid } from "nanoid";
27
- import { SessionManager } from "../session/session-manager.js";
27
+ import { assertSafeSessionId, SessionManager } from "../session/session-manager.js";
28
28
  import { redactLlmConfig, maskSecretValue } from "./redact.js";
29
29
  import { redactSecrets } from "../logging/sanitize-messages.js";
30
+ function isValidRunAttachment(value) {
31
+ if (!value || typeof value !== "object")
32
+ return false;
33
+ const attachment = value;
34
+ const hasPath = [attachment.path, attachment.relPath, attachment.absPath].some((path) => typeof path === "string" && path.trim().length > 0);
35
+ return (typeof attachment.id === "string" &&
36
+ attachment.id.length > 0 &&
37
+ typeof attachment.sessionId === "string" &&
38
+ attachment.sessionId.length > 0 &&
39
+ (attachment.kind === "image" ||
40
+ attachment.kind === "file" ||
41
+ attachment.kind === "directory") &&
42
+ hasPath);
43
+ }
44
+ function runInputError(params) {
45
+ if (typeof params.task !== "string")
46
+ return "task must be a string";
47
+ const hasAttachment = Array.isArray(params.attachments) && params.attachments.some(isValidRunAttachment);
48
+ if (params.task.trim().length === 0 && !hasAttachment) {
49
+ return "task or a valid attachment is required";
50
+ }
51
+ return null;
52
+ }
30
53
  const COMPACT_STREAM_STRATEGIES = new Set([
31
54
  "micro",
32
55
  "summary",
@@ -50,10 +73,17 @@ export class AgentServer {
50
73
  /** Disk-only active-goal reader for agent/goalGet on a non-live session. */
51
74
  readActiveGoalFromDisk;
52
75
  workspaceBridgeEnabled;
76
+ connectionId;
77
+ strictApprovalRouting;
78
+ approvalRouter;
79
+ approvalConnectionUnregister = null;
80
+ disconnected = false;
81
+ pendingApprovalTargets = new Map();
53
82
  /**
54
83
  * Last per-session EngineConfigSlice supplied by agent/run. Kept even after
55
84
  * idle eviction so background-completion wakeups can rebuild the ChatSession
56
- * with the same cwd/permission/trust inputs before draining the queue.
85
+ * with the same cwd/trust inputs before draining the queue. Per-turn
86
+ * permission/plan overrides deliberately do not survive into wakeup turns.
57
87
  */
58
88
  lastSliceBySession = new Map();
59
89
  /** Lazy disk reader for cold wakeup rehydrate when this process lacks a slice. */
@@ -98,12 +128,20 @@ export class AgentServer {
98
128
  * itself outlives the server (it's a process-local singleton).
99
129
  */
100
130
  bgAgentBusUnsubscribe = null;
131
+ wakeupsInFlight = new Set();
101
132
  constructor(options) {
102
133
  this.chatManager = options.chatManager ?? null;
103
134
  this.legacyEngine = options.engine ?? null;
104
135
  this.settingsReader = options.settingsReader ?? null;
105
136
  this.readActiveGoalFromDisk = options.readActiveGoalFromDisk ?? null;
106
137
  this.workspaceBridgeEnabled = options.workspaceBridge === true;
138
+ this.connectionId = options.connectionId ?? "legacy-agent-server";
139
+ this.strictApprovalRouting = options.connectionId !== undefined;
140
+ this.approvalRouter = options.approvalRouter ?? getApprovalRouter();
141
+ if (!this.strictApprovalRouting) {
142
+ this.approvalRouter.deregister(this.connectionId, "legacy approval host replaced");
143
+ }
144
+ getInteractiveApprovalBackend().clearPromptFn();
107
145
  if (!this.chatManager && !this.legacyEngine) {
108
146
  throw new Error("AgentServer: either chatManager or engine must be supplied");
109
147
  }
@@ -116,9 +154,10 @@ export class AgentServer {
116
154
  });
117
155
  }
118
156
  });
119
- setInteractiveApprovalFn((request) => {
120
- return this.requestApprovalFromClient(request);
121
- });
157
+ this.approvalConnectionUnregister = this.approvalRouter.addConnection(this.connectionId, {
158
+ requestApproval: (request, target) => this.requestApprovalFromClient(request, target),
159
+ ownershipLost: (targets, reason) => this.failClosedApprovalTargets(targets, reason),
160
+ }, { claimUnownedSessions: !this.strictApprovalRouting });
122
161
  if (this.legacyEngine) {
123
162
  // Only wire an interactive askUser when a human is present. For
124
163
  // unattended (headless) runs we leave askUser undefined so
@@ -174,48 +213,60 @@ export class AgentServer {
174
213
  * the first drains all currently-pending items; subsequent bus events for
175
214
  * the same session find it busy (or find an empty queue) and no-op.
176
215
  *
177
- * INVARIANT (the burst-merge correctness depends on it): the merge only
178
- * holds because (a) the bus fans out to subscribers SYNCHRONOUSLY, and (b)
179
- * `enqueueTurn` sets the session's `active` (→ isBusy()===true) SYNCHRONOUSLY
180
- * before its first await. So when bus events #2..N arrive — still on the same
181
- * synchronous fan-out as #1 — they already see the session as busy and no-op.
182
- * If either path is ever made async (e.g. an await is added inside
183
- * enqueueTurn before `active` is set, or the bus starts deferring delivery),
184
- * this degrades into N concurrent wakeups for N completions. Keep both
185
- * synchronous, or replace this comment's assumption with an explicit
186
- * "wakeup in flight" guard flag.
216
+ * `wakeupsInFlight` serializes the now-async rehydrate path, so a burst of
217
+ * completion events cannot create duplicate sessions or enqueue duplicate
218
+ * wakeup turns while getOrCreate waits for a closing generation to settle.
187
219
  */
188
220
  maybeWakeIdleSession(sessionId) {
189
- if (!this.chatManager)
221
+ if (this.wakeupsInFlight.has(sessionId))
190
222
  return;
191
- const session = this.chatManager.get(sessionId) ?? this.rehydrateSessionForWake(sessionId);
223
+ this.wakeupsInFlight.add(sessionId);
224
+ void this.wakeIdleSession(sessionId)
225
+ .then((ranTurn) => {
226
+ this.wakeupsInFlight.delete(sessionId);
227
+ if (ranTurn && notificationQueue.getSnapshot(sessionId).length > 0) {
228
+ this.maybeWakeIdleSession(sessionId);
229
+ }
230
+ })
231
+ .catch(() => {
232
+ this.wakeupsInFlight.delete(sessionId);
233
+ });
234
+ }
235
+ async wakeIdleSession(sessionId) {
236
+ if (!this.chatManager)
237
+ return false;
238
+ if (this.chatManager.isUnavailable(sessionId))
239
+ return false;
240
+ const session = this.chatManager.get(sessionId) ?? (await this.rehydrateSessionForWake(sessionId));
192
241
  if (!session || session.isBusy())
193
- return;
242
+ return false;
194
243
  // Headless / automation runs are one-shot: the caller takes result.text and
195
244
  // is gone, so there's no consumer for a woken continuation turn. Headless
196
245
  // already drained its background sub-agents inside engine.run before
197
246
  // returning; any remaining queued notification (video/shell) must NOT spin
198
247
  // an orphan turn. Only the interactive path auto-continues.
199
248
  if (session.engine.isHeadless())
200
- return;
249
+ return false;
201
250
  // Don't resurrect a session the user just Stopped: cancel() leaves it idle
202
251
  // (active=null) so isBusy() reads false, but auto-running a fresh turn here
203
252
  // would defeat the Stop. The flag clears the moment the user sends again.
204
253
  if (session.wasCancelledSinceLastTurn())
205
- return;
254
+ return false;
206
255
  const pending = notificationQueue.drainAll(sessionId);
207
256
  if (pending.length === 0)
208
- return;
257
+ return false;
209
258
  const task = `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`;
210
- void session
211
- .enqueueTurn(task, {
212
- // Synthetic notification, not the user's own input: persisted with an
213
- // `injected` flag so a disk rebuild doesn't render it as a phantom user
214
- // bubble (the live UI shows only the woken assistant's reply).
215
- injected: true,
216
- onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
217
- })
218
- .catch((err) => {
259
+ try {
260
+ await session.enqueueTurn(task, {
261
+ // Synthetic notification, not the user's own input: persisted with an
262
+ // `injected` flag so a disk rebuild doesn't render it as a phantom user
263
+ // bubble (the live UI shows only the woken assistant's reply).
264
+ injected: true,
265
+ onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
266
+ approvalRouter: this.approvalRouter,
267
+ });
268
+ }
269
+ catch (err) {
219
270
  // A wakeup turn failing must not crash the bus fan-out. The drained
220
271
  // notifications are already in the transcript via the run's messages;
221
272
  // log and move on.
@@ -236,21 +287,14 @@ export class AgentServer {
236
287
  sessionId,
237
288
  event: { type: "error", error: err?.message ?? "background wakeup failed" },
238
289
  });
239
- })
240
- .finally(() => {
241
- // Run-boundary re-check (trigger B): the woken summarize turn may have
242
- // spawned NEW background work (e.g. goal "generate 2 videos" → after #1
243
- // completes, this turn submits #2). When #2 finishes its notification
244
- // arrives while we're idle again — but if it arrived DURING this turn
245
- // (busy), trigger A skipped it. Re-checking at the run boundary drains
246
- // anything that landed while busy, chaining wakeups until the queue is
247
- // truly empty. Replaces the old engine for(;;) outer loop.
248
- this.maybeWakeIdleSession(sessionId);
249
- });
290
+ }
291
+ return true;
250
292
  }
251
- rehydrateSessionForWake(sessionId) {
293
+ async rehydrateSessionForWake(sessionId) {
252
294
  if (!this.chatManager)
253
295
  return null;
296
+ if (this.chatManager.isUnavailable(sessionId))
297
+ return null;
254
298
  try {
255
299
  const pending = notificationQueue.getSnapshot(sessionId);
256
300
  if (pending.length === 0) {
@@ -273,7 +317,7 @@ export class AgentServer {
273
317
  });
274
318
  return null;
275
319
  }
276
- const session = this.chatManager.getOrCreate(sessionId, slice);
320
+ const session = await this.chatManager.getOrCreate(sessionId, slice);
277
321
  this.wireInteractiveSession(session, sessionId);
278
322
  logger.debug("bg_wakeup.rehydrated_session", {
279
323
  sessionId,
@@ -300,7 +344,6 @@ export class AgentServer {
300
344
  if (!cwd)
301
345
  return null;
302
346
  return {
303
- permissionMode: "default",
304
347
  projectTrusted: false,
305
348
  cwd,
306
349
  };
@@ -324,6 +367,9 @@ export class AgentServer {
324
367
  case Methods.Run:
325
368
  await this.handleRun(req);
326
369
  break;
370
+ case Methods.ForkSession:
371
+ this.handleForkSession(req);
372
+ break;
327
373
  case Methods.Approve:
328
374
  this.handleApprove(req);
329
375
  break;
@@ -346,7 +392,7 @@ export class AgentServer {
346
392
  this.handleUnsteer(req);
347
393
  break;
348
394
  case Methods.CloseSession:
349
- this.handleCloseSession(req);
395
+ await this.handleCloseSession(req);
350
396
  break;
351
397
  case Methods.ReleaseWorkspace:
352
398
  this.handleReleaseWorkspace(req);
@@ -371,6 +417,73 @@ export class AgentServer {
371
417
  }
372
418
  }
373
419
  // ─── Run ────────────────────────────────────────────────────────
420
+ handleForkSession(req) {
421
+ const params = req.params ?? {};
422
+ const sourceSessionId = typeof params.sourceSessionId === "string" ? params.sourceSessionId : "";
423
+ const targetSessionId = typeof params.targetSessionId === "string" ? params.targetSessionId : undefined;
424
+ const throughEventId = typeof params.throughEventId === "string" ? params.throughEventId : undefined;
425
+ if (!sourceSessionId || params.mode !== "full") {
426
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "forkSession requires sourceSessionId and mode=full"));
427
+ return;
428
+ }
429
+ if (params.targetSessionId !== undefined && typeof params.targetSessionId !== "string") {
430
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "targetSessionId must be a string"));
431
+ return;
432
+ }
433
+ if (params.throughEventId !== undefined && typeof params.throughEventId !== "string") {
434
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "throughEventId must be a string"));
435
+ return;
436
+ }
437
+ try {
438
+ assertSafeSessionId(sourceSessionId);
439
+ if (targetSessionId)
440
+ assertSafeSessionId(targetSessionId);
441
+ }
442
+ catch (err) {
443
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, err instanceof Error ? err.message : String(err)));
444
+ return;
445
+ }
446
+ let source;
447
+ try {
448
+ source = this.chatManager?.get(sourceSessionId);
449
+ }
450
+ catch (err) {
451
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, err instanceof Error ? err.message : String(err)));
452
+ return;
453
+ }
454
+ const engine = source?.engine ?? this.legacyEngine ?? this.anyEngine();
455
+ if (!engine || !engine.sessionExistsOnDisk(sourceSessionId)) {
456
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionNotFound, `Session not found: ${sourceSessionId}`));
457
+ return;
458
+ }
459
+ if (targetSessionId && engine.sessionExistsOnDisk(targetSessionId)) {
460
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `Session already exists: ${targetSessionId}`));
461
+ return;
462
+ }
463
+ try {
464
+ const result = engine.forkSession(sourceSessionId, { targetSessionId, throughEventId });
465
+ const workspace = result.bundle.state.workspace ?? {
466
+ root: result.bundle.state.cwd,
467
+ kind: "main",
468
+ };
469
+ this.transport.send(createResponse(req.id, {
470
+ sessionId: result.bundle.state.sessionId,
471
+ mode: "full",
472
+ forkedFrom: result.lineage,
473
+ workspace,
474
+ copiedEventCount: result.copiedEventCount,
475
+ }));
476
+ }
477
+ catch (err) {
478
+ const message = err instanceof Error ? err.message : String(err);
479
+ const code = /not found/i.test(message)
480
+ ? ErrorCodes.SessionNotFound
481
+ : /already exists|invalid|cursor|metadata|unfinished|orphaned|unsupported|malformed/i.test(message)
482
+ ? ErrorCodes.InvalidParams
483
+ : ErrorCodes.InternalError;
484
+ this.transport.send(createErrorResponse(req.id, code, message));
485
+ }
486
+ }
374
487
  async handleRun(req) {
375
488
  // ── ChatSessionManager path (multi-session) ──────────────────
376
489
  if (this.chatManager) {
@@ -386,12 +499,12 @@ export class AgentServer {
386
499
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
387
500
  return;
388
501
  }
389
- if (!params.task) {
390
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "task is required"));
502
+ const inputError = runInputError(params);
503
+ if (inputError) {
504
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, inputError));
391
505
  return;
392
506
  }
393
507
  const sessionConfig = {
394
- permissionMode: params.permissionMode,
395
508
  cwd: params.cwd,
396
509
  projectTrusted: params.projectTrusted,
397
510
  goal: typeof params.goal === "string" || (params.goal != null && typeof params.goal === "object")
@@ -415,13 +528,19 @@ export class AgentServer {
415
528
  }
416
529
  let session;
417
530
  try {
418
- session = cm.getOrCreate(params.sessionId, sessionConfig);
531
+ session = await cm.getOrCreate(params.sessionId, sessionConfig);
419
532
  }
420
533
  catch (err) {
421
534
  const code = err.code ?? ErrorCodes.InternalError;
422
535
  this.transport.send(createErrorResponse(req.id, code, err.message));
423
536
  return;
424
537
  }
538
+ const sid = params.sessionId;
539
+ const approvalRegistration = this.approvalRouter.register(sid, this.connectionId);
540
+ if (!approvalRegistration.ok) {
541
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, `Session ${sid} is owned by another connection`));
542
+ return;
543
+ }
425
544
  this.rememberSessionSlice(params.sessionId, sessionConfig);
426
545
  if (params.model !== undefined) {
427
546
  if (typeof params.model !== "string" || params.model.length === 0) {
@@ -436,16 +555,12 @@ export class AgentServer {
436
555
  return;
437
556
  }
438
557
  }
439
- if (typeof params.planMode === "boolean") {
440
- session.engine.setPlanMode(params.planMode);
441
- }
442
- const sid = params.sessionId;
443
558
  // Wire AskUserQuestion and host bridges for this interactive session. The
444
559
  // chatManager path builds a fresh per-session Engine via engineFactory, so
445
560
  // these host callbacks must be installed after every create/recreate.
446
561
  this.wireInteractiveSession(session, sid);
447
562
  try {
448
- const result = await session.enqueueTurn(params.task, {
563
+ const run = session.enqueueTurn(params.task, {
449
564
  cwd: params.cwd,
450
565
  attachments: Array.isArray(params.attachments) ? params.attachments : undefined,
451
566
  goal: typeof params.goal === "string" ||
@@ -454,7 +569,12 @@ export class AgentServer {
454
569
  : undefined,
455
570
  onStream: (event) => this.notify(Methods.StreamEvent, { sessionId: sid, event }),
456
571
  clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
572
+ permissionMode: params.permissionMode,
573
+ planMode: params.planMode,
574
+ approvalRouter: this.approvalRouter,
457
575
  });
576
+ this.notify(Methods.RunAccepted, { requestId: req.id, sessionId: sid });
577
+ const result = await run;
458
578
  const runResult = {
459
579
  text: result.text,
460
580
  reason: result.reason,
@@ -483,10 +603,18 @@ export class AgentServer {
483
603
  return;
484
604
  }
485
605
  const params = (req.params ?? {});
486
- if (!params.task) {
487
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "task is required"));
606
+ const inputError = runInputError(params);
607
+ if (inputError) {
608
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, inputError));
488
609
  return;
489
610
  }
611
+ if (typeof params.sessionId === "string" && params.sessionId.length > 0) {
612
+ const approvalRegistration = this.approvalRouter.register(params.sessionId, this.connectionId);
613
+ if (!approvalRegistration.ok) {
614
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, `Session ${params.sessionId} is owned by another connection`));
615
+ return;
616
+ }
617
+ }
490
618
  if (params.cwd !== undefined && (typeof params.cwd !== "string" || params.cwd.length === 0)) {
491
619
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "cwd must be a non-empty string"));
492
620
  return;
@@ -528,12 +656,17 @@ export class AgentServer {
528
656
  // `signal.aborted` below it's already gone.
529
657
  const runController = this.abortController;
530
658
  try {
659
+ this.notify(Methods.RunAccepted, {
660
+ requestId: req.id,
661
+ sessionId: params.sessionId ?? "",
662
+ });
531
663
  const result = await this.legacyEngine.run(params.task, {
532
664
  cwd: params.cwd,
533
665
  sessionId: params.sessionId,
534
666
  signal: runController.signal,
535
667
  onStream: streamToClient,
536
668
  clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
669
+ attachments: Array.isArray(params.attachments) ? params.attachments : undefined,
537
670
  goal: typeof params.goal === "string" ||
538
671
  (params.goal != null && typeof params.goal === "object")
539
672
  ? params.goal
@@ -579,6 +712,19 @@ export class AgentServer {
579
712
  // ─── Approve ────────────────────────────────────────────────────
580
713
  handleApprove(req) {
581
714
  const params = (req.params ?? {});
715
+ if (this.strictApprovalRouting) {
716
+ const pending = this.pendingApprovalTargets.get(params.requestId);
717
+ const matchesPending = pending !== undefined &&
718
+ params.connectionId === this.connectionId &&
719
+ params.sessionId === pending.sessionId &&
720
+ params.connectionId === pending.connectionId &&
721
+ params.generation === pending.generation &&
722
+ this.approvalRouter.matches(pending);
723
+ if (!matchesPending) {
724
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "Approval response does not match connectionId, sessionId, generation, and requestId"));
725
+ return;
726
+ }
727
+ }
582
728
  // ChatSessionManager path: approvals are scoped by (sessionId, requestId).
583
729
  // Never fall back from a session-tagged response to the legacy global map:
584
730
  // a stale/misrouted UI response must fail closed instead of resolving a
@@ -595,6 +741,7 @@ export class AgentServer {
595
741
  return;
596
742
  }
597
743
  s.pendingApprovals.delete(params.requestId);
744
+ this.pendingApprovalTargets.delete(params.requestId);
598
745
  // Cancel the pending timeout for requests that arm one (browser actions,
599
746
  // credential injection, and tool approvals). AskUserQuestion does not use
600
747
  // a timeout; this is harmless for those request ids.
@@ -610,6 +757,7 @@ export class AgentServer {
610
757
  return;
611
758
  }
612
759
  this.pendingApprovals.delete(params.requestId);
760
+ this.pendingApprovalTargets.delete(params.requestId);
613
761
  this.clearApprovalTimer(params.requestId);
614
762
  resolve(params.decision);
615
763
  this.transport.send(createResponse(req.id, { ok: true }));
@@ -817,7 +965,7 @@ export class AgentServer {
817
965
  this.transport.send(createResponse(req.id, { items }));
818
966
  }
819
967
  // ─── CloseSession ───────────────────────────────────────────────
820
- handleCloseSession(req) {
968
+ async handleCloseSession(req) {
821
969
  const params = (req.params ?? {});
822
970
  if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
823
971
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
@@ -828,13 +976,15 @@ export class AgentServer {
828
976
  if (session) {
829
977
  this.cancelSessionApprovals(session, "session closed");
830
978
  }
831
- this.chatManager.close(params.sessionId);
979
+ this.approvalRouter.release(params.sessionId, this.connectionId, "session closed");
980
+ await this.chatManager.close(params.sessionId);
832
981
  }
833
982
  // Explicit session teardown — reap that session's background shells
834
983
  // (design §6 "session 被显式删除 → killSession"). This is the RPC path
835
984
  // (agent/closeSession from the host on delete), distinct from the idle
836
- // sweeper's chatManager.close() which must NOT kill (§6). Fire-and-forget.
837
- void backgroundShellManager.killSession(params.sessionId);
985
+ // sweeper's idle eviction which must NOT kill (§6). Await teardown so the
986
+ // close RPC is a real deletion barrier for Desktop.
987
+ await backgroundShellManager.killSession(params.sessionId);
838
988
  // Drop this session's retained background jobs too (#2/#5): finished jobs
839
989
  // are kept for the panel, so explicit teardown is where they're released.
840
990
  backgroundJobRegistry.dropForSession(params.sessionId);
@@ -1127,6 +1277,10 @@ export class AgentServer {
1127
1277
  let compactEngine = engine;
1128
1278
  if (this.chatManager) {
1129
1279
  if (compactSessionId) {
1280
+ if (this.chatManager.isUnavailable(compactSessionId)) {
1281
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${compactSessionId}`));
1282
+ return;
1283
+ }
1130
1284
  let session = this.chatManager.get(compactSessionId);
1131
1285
  if (!session) {
1132
1286
  const probeEngine = this.anyEngine();
@@ -1135,7 +1289,7 @@ export class AgentServer {
1135
1289
  return;
1136
1290
  }
1137
1291
  try {
1138
- session = this.chatManager.getOrCreate(compactSessionId, {
1292
+ session = await this.chatManager.getOrCreate(compactSessionId, {
1139
1293
  cwd: probeEngine.getSessionManager().readCwd(compactSessionId),
1140
1294
  });
1141
1295
  }
@@ -1452,6 +1606,10 @@ export class AgentServer {
1452
1606
  }
1453
1607
  // In the chatManager path, inject into the session's engine
1454
1608
  if (this.chatManager) {
1609
+ if (this.chatManager.isUnavailable(params.sessionId)) {
1610
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${params.sessionId}`));
1611
+ return;
1612
+ }
1455
1613
  const s = this.chatManager.get(params.sessionId);
1456
1614
  if (!s) {
1457
1615
  this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `No such session: ${params.sessionId}`));
@@ -1485,6 +1643,10 @@ export class AgentServer {
1485
1643
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "text and sessionId required"));
1486
1644
  return;
1487
1645
  }
1646
+ if (this.chatManager?.isUnavailable(params.sessionId)) {
1647
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${params.sessionId}`));
1648
+ return;
1649
+ }
1488
1650
  const engine = this.chatManager
1489
1651
  ? this.chatManager.get(params.sessionId)?.engine
1490
1652
  : this.legacyEngine;
@@ -1506,6 +1668,10 @@ export class AgentServer {
1506
1668
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "id and sessionId required"));
1507
1669
  return;
1508
1670
  }
1671
+ if (this.chatManager?.isUnavailable(params.sessionId)) {
1672
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${params.sessionId}`));
1673
+ return;
1674
+ }
1509
1675
  const engine = this.chatManager
1510
1676
  ? this.chatManager.get(params.sessionId)?.engine
1511
1677
  : this.legacyEngine;
@@ -1527,10 +1693,15 @@ export class AgentServer {
1527
1693
  /**
1528
1694
  * Ask the client to approve a tool operation (legacy single-engine path).
1529
1695
  */
1530
- requestApprovalFromClient(request) {
1696
+ requestApprovalFromClient(request, route) {
1531
1697
  return new Promise((resolve) => {
1532
1698
  const requestId = nanoid(12);
1533
- const sessionId = typeof request.sessionId === "string" ? request.sessionId : undefined;
1699
+ const effectiveRoute = route ?? {
1700
+ connectionId: this.connectionId,
1701
+ sessionId: request.sessionId ?? "",
1702
+ generation: 0,
1703
+ };
1704
+ const sessionId = effectiveRoute.sessionId;
1534
1705
  const session = this.chatManager && sessionId ? this.chatManager.get(sessionId) : undefined;
1535
1706
  if (this.chatManager && sessionId && !session) {
1536
1707
  resolve({ approved: false, reason: `session closed: ${sessionId}` });
@@ -1544,22 +1715,34 @@ export class AgentServer {
1544
1715
  else {
1545
1716
  this.pendingApprovals.set(requestId, resolve);
1546
1717
  }
1718
+ this.pendingApprovalTargets.set(requestId, { ...effectiveRoute, requestId });
1547
1719
  const timer = setTimeout(() => {
1548
1720
  const pending = session?.pendingApprovals ?? this.pendingApprovals;
1549
1721
  if (pending.has(requestId)) {
1550
1722
  pending.delete(requestId);
1723
+ this.pendingApprovalTargets.delete(requestId);
1551
1724
  this.approvalTimers.delete(requestId);
1552
1725
  resolve({ approved: false, reason: "approval timed out" });
1553
1726
  }
1554
1727
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1555
1728
  this.approvalTimers.set(requestId, timer);
1556
1729
  this.notify(Methods.ApprovalRequest, {
1730
+ connectionId: effectiveRoute.connectionId,
1557
1731
  ...(sessionId ? { sessionId } : {}),
1732
+ generation: effectiveRoute.generation,
1558
1733
  requestId,
1559
1734
  request,
1560
1735
  });
1561
1736
  });
1562
1737
  }
1738
+ approvalRouteEnvelope(sessionId, requestId) {
1739
+ const route = this.approvalRouter.current(sessionId);
1740
+ if (!route || route.connectionId !== this.connectionId)
1741
+ return { sessionId, requestId };
1742
+ const target = { ...route, requestId };
1743
+ this.pendingApprovalTargets.set(requestId, target);
1744
+ return target;
1745
+ }
1563
1746
  /**
1564
1747
  * Per-session AskUserQuestion for the chatManager path. Resolves via the
1565
1748
  * SESSION's pendingApprovals (the chatManager approve handler looks there,
@@ -1593,8 +1776,7 @@ export class AgentServer {
1593
1776
  if (opts?.optionsOnly !== undefined)
1594
1777
  args.optionsOnly = opts.optionsOnly;
1595
1778
  this.notify(Methods.ApprovalRequest, {
1596
- sessionId,
1597
- requestId,
1779
+ ...this.approvalRouteEnvelope(sessionId, requestId),
1598
1780
  request: {
1599
1781
  toolName: "__ask_user__",
1600
1782
  args,
@@ -1622,6 +1804,7 @@ export class AgentServer {
1622
1804
  const resolveFn = session.pendingApprovals.get(requestId);
1623
1805
  if (resolveFn) {
1624
1806
  session.pendingApprovals.delete(requestId);
1807
+ this.pendingApprovalTargets.delete(requestId);
1625
1808
  this.approvalTimers.delete(requestId);
1626
1809
  resolveFn("(用户未在限定时间内回答;目标运行中请自行做出合理假设并继续推进。)");
1627
1810
  // Tell every client the ask is resolved (nobody answered) so the
@@ -1691,14 +1874,14 @@ export class AgentServer {
1691
1874
  const timer = setTimeout(() => {
1692
1875
  if (session.pendingApprovals.has(requestId)) {
1693
1876
  session.pendingApprovals.delete(requestId);
1877
+ this.pendingApprovalTargets.delete(requestId);
1694
1878
  this.approvalTimers.delete(requestId);
1695
1879
  resolve({ ok: false, detail: "browser action timed out" });
1696
1880
  }
1697
1881
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1698
1882
  this.approvalTimers.set(requestId, timer);
1699
1883
  this.notify(Methods.ApprovalRequest, {
1700
- sessionId,
1701
- requestId,
1884
+ ...this.approvalRouteEnvelope(sessionId, requestId),
1702
1885
  request: {
1703
1886
  toolName: "__browser_action__",
1704
1887
  args: { action, ...payload },
@@ -1741,14 +1924,14 @@ export class AgentServer {
1741
1924
  const timer = setTimeout(() => {
1742
1925
  if (session.pendingApprovals.has(requestId)) {
1743
1926
  session.pendingApprovals.delete(requestId);
1927
+ this.pendingApprovalTargets.delete(requestId);
1744
1928
  this.approvalTimers.delete(requestId);
1745
1929
  resolve({ ok: false, error: "credential inject timed out" });
1746
1930
  }
1747
1931
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1748
1932
  this.approvalTimers.set(requestId, timer);
1749
1933
  this.notify(Methods.ApprovalRequest, {
1750
- sessionId,
1751
- requestId,
1934
+ ...this.approvalRouteEnvelope(sessionId, requestId),
1752
1935
  request: {
1753
1936
  toolName: "__credential_action__",
1754
1937
  args: { action: "injectCookie", credentialId, credentialScope },
@@ -1795,14 +1978,14 @@ export class AgentServer {
1795
1978
  const timer = setTimeout(() => {
1796
1979
  if (session.pendingApprovals.has(requestId)) {
1797
1980
  session.pendingApprovals.delete(requestId);
1981
+ this.pendingApprovalTargets.delete(requestId);
1798
1982
  this.approvalTimers.delete(requestId);
1799
1983
  reject(new Error("workspace switch timed out"));
1800
1984
  }
1801
1985
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1802
1986
  this.approvalTimers.set(requestId, timer);
1803
1987
  this.notify(Methods.ApprovalRequest, {
1804
- sessionId,
1805
- requestId,
1988
+ ...this.approvalRouteEnvelope(sessionId, requestId),
1806
1989
  request: {
1807
1990
  toolName: "__workspace_action__",
1808
1991
  args: { action: "switch", target },
@@ -1876,6 +2059,7 @@ export class AgentServer {
1876
2059
  }
1877
2060
  // ─── Lifecycle ──────────────────────────────────────────────────
1878
2061
  close() {
2062
+ this.disconnect("server closing");
1879
2063
  // Detach the bg-agent bus subscription first so a final flurry of
1880
2064
  // completions during shutdown can't race the `shutdown` status
1881
2065
  // notify below. Safe to call repeatedly — the unsubscribe is a
@@ -1912,6 +2096,18 @@ export class AgentServer {
1912
2096
  this.notify(Methods.Status, { status: "shutdown" });
1913
2097
  this.transport.close();
1914
2098
  }
2099
+ /** Release only this transport's approval ownership. Safe on socket close
2100
+ * even when the ChatSessionManager is shared by other TCP connections. */
2101
+ disconnect(reason = "approval connection disconnected") {
2102
+ if (this.disconnected)
2103
+ return;
2104
+ this.disconnected = true;
2105
+ const unregister = this.approvalConnectionUnregister;
2106
+ this.approvalConnectionUnregister = null;
2107
+ if (unregister) {
2108
+ this.approvalRouter.deregister(this.connectionId, reason);
2109
+ }
2110
+ }
1915
2111
  clearApprovalTimer(requestId) {
1916
2112
  const timer = this.approvalTimers.get(requestId);
1917
2113
  if (timer) {
@@ -1929,6 +2125,7 @@ export class AgentServer {
1929
2125
  cancelSessionApprovals(session, reason = "cancelled") {
1930
2126
  for (const [requestId, resolve] of session.pendingApprovals) {
1931
2127
  this.clearApprovalTimer(requestId);
2128
+ this.pendingApprovalTargets.delete(requestId);
1932
2129
  try {
1933
2130
  resolve({ approved: false, reason });
1934
2131
  }
@@ -1938,6 +2135,27 @@ export class AgentServer {
1938
2135
  }
1939
2136
  session.pendingApprovals.clear();
1940
2137
  }
2138
+ failClosedApprovalTargets(targets, reason) {
2139
+ for (const target of targets) {
2140
+ const session = this.chatManager?.get(target.sessionId);
2141
+ if (session) {
2142
+ this.cancelSessionApprovals(session, reason);
2143
+ continue;
2144
+ }
2145
+ for (const [requestId, pending] of this.pendingApprovalTargets) {
2146
+ if (pending.connectionId !== target.connectionId ||
2147
+ pending.sessionId !== target.sessionId ||
2148
+ pending.generation !== target.generation) {
2149
+ continue;
2150
+ }
2151
+ const resolve = this.pendingApprovals.get(requestId);
2152
+ this.pendingApprovals.delete(requestId);
2153
+ this.pendingApprovalTargets.delete(requestId);
2154
+ this.clearApprovalTimer(requestId);
2155
+ resolve?.({ approved: false, reason });
2156
+ }
2157
+ }
2158
+ }
1941
2159
  clearAllApprovalTimers() {
1942
2160
  for (const timer of this.approvalTimers.values()) {
1943
2161
  clearTimeout(timer);