@cjhyy/code-shell-core 0.8.20 → 0.9.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 (74) hide show
  1. package/dist/automation/desktop-authority-client.d.ts +9 -0
  2. package/dist/automation/desktop-authority-client.js +47 -0
  3. package/dist/automation/scheduler.d.ts +8 -0
  4. package/dist/automation/scheduler.js +37 -1
  5. package/dist/automation/store.js +14 -1
  6. package/dist/capabilities/index.d.ts +1 -0
  7. package/dist/cli/agent-server-stdio.js +4 -1
  8. package/dist/engine/engine-workspace-authority.d.ts +50 -0
  9. package/dist/engine/engine-workspace-authority.js +167 -0
  10. package/dist/engine/engine.d.ts +7 -10
  11. package/dist/engine/engine.js +87 -101
  12. package/dist/engine/input-attachments.d.ts +1 -0
  13. package/dist/engine/input-attachments.js +6 -2
  14. package/dist/engine/run-environment.d.ts +8 -3
  15. package/dist/engine/run-environment.js +33 -8
  16. package/dist/engine/run-image-input.d.ts +1 -0
  17. package/dist/engine/run-image-input.js +1 -0
  18. package/dist/engine/run-session-open.d.ts +2 -1
  19. package/dist/engine/run-session-open.js +6 -0
  20. package/dist/engine/run-setup.d.ts +1 -0
  21. package/dist/engine/run-setup.js +2 -1
  22. package/dist/engine/run-tooling.d.ts +2 -0
  23. package/dist/engine/run-tooling.js +3 -0
  24. package/dist/engine/run-types.d.ts +2 -0
  25. package/dist/engine/run-workspace.d.ts +6 -1
  26. package/dist/engine/run-workspace.js +47 -0
  27. package/dist/engine/subagent-spawner.d.ts +1 -0
  28. package/dist/engine/subagent-spawner.js +1 -0
  29. package/dist/engine/types.d.ts +2 -0
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.extension.d.ts +1 -1
  32. package/dist/index.internal.d.ts +2 -2
  33. package/dist/index.internal.js +1 -0
  34. package/dist/index.js +1 -1
  35. package/dist/plugins/pluginAutomationTemplates.d.ts +2 -0
  36. package/dist/plugins/pluginAutomationTemplates.js +4 -0
  37. package/dist/prompt/composer.d.ts +4 -1
  38. package/dist/prompt/composer.js +11 -3
  39. package/dist/protocol/background-result-wakeup.d.ts +18 -0
  40. package/dist/protocol/background-result-wakeup.js +101 -0
  41. package/dist/protocol/chat-session-manager.d.ts +42 -1
  42. package/dist/protocol/chat-session-manager.js +167 -4
  43. package/dist/protocol/chat-session.d.ts +9 -1
  44. package/dist/protocol/chat-session.js +20 -2
  45. package/dist/protocol/mobile-remote-types.d.ts +15 -0
  46. package/dist/protocol/server.d.ts +5 -6
  47. package/dist/protocol/server.js +62 -128
  48. package/dist/protocol/session-workspace-rpc.d.ts +23 -0
  49. package/dist/protocol/session-workspace-rpc.js +171 -0
  50. package/dist/protocol/types.d.ts +45 -1
  51. package/dist/protocol/types.js +4 -0
  52. package/dist/session/session-manager.d.ts +18 -0
  53. package/dist/session/session-manager.js +87 -0
  54. package/dist/settings/manager.d.ts +7 -0
  55. package/dist/settings/manager.js +64 -3
  56. package/dist/tool-system/builtin/agent-notifications.d.ts +8 -0
  57. package/dist/tool-system/builtin/agent-notifications.js +19 -0
  58. package/dist/tool-system/builtin/config.d.ts +11 -0
  59. package/dist/tool-system/builtin/config.js +55 -10
  60. package/dist/tool-system/builtin/cron.d.ts +11 -0
  61. package/dist/tool-system/builtin/cron.js +27 -2
  62. package/dist/tool-system/builtin/edit.js +5 -3
  63. package/dist/tool-system/builtin/view-image.js +8 -2
  64. package/dist/tool-system/builtin/write.js +5 -3
  65. package/dist/tool-system/context.d.ts +4 -1
  66. package/dist/tool-system/executor.js +1 -1
  67. package/dist/tool-system/path-policy.d.ts +11 -3
  68. package/dist/tool-system/path-policy.js +56 -36
  69. package/dist/types.d.ts +6 -0
  70. package/dist/workspace/canonical-key.d.ts +7 -0
  71. package/dist/workspace/canonical-key.js +39 -0
  72. package/dist/workspace/workspace-context.d.ts +35 -0
  73. package/dist/workspace/workspace-context.js +128 -0
  74. package/package.json +1 -1
@@ -34,6 +34,9 @@ export class ChatSessionManager {
34
34
  closingSessions = new Map();
35
35
  closedSessions = new Set();
36
36
  sessionGeneration = new Map();
37
+ sessionSlices = new Map();
38
+ migrationClaims = new Map();
39
+ residentMigrations = new Map();
37
40
  runtime;
38
41
  /** Identity scope this manager serves ("local" unless injected). */
39
42
  identity;
@@ -78,11 +81,133 @@ export class ChatSessionManager {
78
81
  });
79
82
  }
80
83
  async getOrCreate(sessionId, slice) {
81
- const closing = this.closingSessions.get(sessionId);
82
- if (closing) {
83
- await closing;
84
+ // A non-resident migration claim is a short ownership handoff to Main.
85
+ // Wait rather than fail the user's run: once Main atomically commits (or
86
+ // aborts) and releases the token, the Engine is created from current disk.
87
+ for (;;) {
88
+ const residentMigration = this.residentMigrations.get(sessionId);
89
+ if (residentMigration) {
90
+ await residentMigration.released;
91
+ continue;
92
+ }
93
+ const claim = this.migrationClaims.get(sessionId);
94
+ if (claim) {
95
+ await claim.released;
96
+ continue;
97
+ }
98
+ const closing = this.closingSessions.get(sessionId);
99
+ if (closing) {
100
+ await closing;
101
+ continue;
102
+ }
103
+ // No await separates the final claim check from getOrCreateNow. On this
104
+ // process's event loop, either this creates the resident owner first or
105
+ // beginSessionMigration installs the fence first; both cannot win.
106
+ return this.getOrCreateNow(sessionId, slice);
107
+ }
108
+ }
109
+ /**
110
+ * Prove whether this worker currently owns a resident Engine, or fence the
111
+ * Session so Main can perform one durable migration without a re-resume race.
112
+ */
113
+ beginSessionMigration(sessionId, ownershipToken) {
114
+ if (this.residentMigrations.has(sessionId)) {
115
+ return { status: "failed", error: `Session ${sessionId} migration is already in progress` };
116
+ }
117
+ const resident = this.sessions.get(sessionId);
118
+ if (resident)
119
+ return { status: "resident", session: resident };
120
+ if (this.closingSessions.has(sessionId)) {
121
+ return { status: "failed", error: `Session ${sessionId} is closing` };
122
+ }
123
+ if (this.migrationClaims.has(sessionId)) {
124
+ return { status: "failed", error: `Session ${sessionId} migration is already in progress` };
125
+ }
126
+ let release;
127
+ const released = new Promise((resolve) => {
128
+ release = resolve;
129
+ });
130
+ this.migrationClaims.set(sessionId, { ownershipToken, released, release });
131
+ return { status: "not-resident", ownershipToken };
132
+ }
133
+ /** Release only the exact claim minted for this Session. */
134
+ completeSessionMigration(sessionId, ownershipToken) {
135
+ const claim = this.migrationClaims.get(sessionId);
136
+ if (!claim || claim.ownershipToken !== ownershipToken)
137
+ return false;
138
+ this.migrationClaims.delete(sessionId);
139
+ claim.release();
140
+ return true;
141
+ }
142
+ /**
143
+ * Rebuild an idle resident Engine against a new authoritative main root.
144
+ *
145
+ * Transaction order is intentional:
146
+ * 1. construct and restore the candidate while durable state still points at
147
+ * the old owner (factory/restore failure is therefore a no-op),
148
+ * 2. atomically commit durable state through the old owning Engine,
149
+ * 3. synchronously swap the Engine inside the existing ChatSession,
150
+ * 4. dispose the unreachable old Engine before reporting success.
151
+ *
152
+ * No await occurs between the idle check, durable commit and owner swap.
153
+ */
154
+ async migrateResidentSessionMainRoot(sessionId, target) {
155
+ const session = this.sessions.get(sessionId);
156
+ if (!session)
157
+ throw new Error(`Session ${sessionId} is not resident`);
158
+ if (session.isBusy() || session.queueDepth() > 0) {
159
+ throw new Error(`Session ${sessionId} is running or has queued turns`);
160
+ }
161
+ if (this.residentMigrations.has(sessionId) || this.migrationClaims.has(sessionId)) {
162
+ throw new Error(`Session ${sessionId} migration is already in progress`);
163
+ }
164
+ let release;
165
+ const released = new Promise((resolve) => {
166
+ release = resolve;
167
+ });
168
+ this.residentMigrations.set(sessionId, { released, release });
169
+ const previous = session.engine;
170
+ let candidate;
171
+ let committed = false;
172
+ try {
173
+ const previousSlice = this.sessionSlices.get(sessionId) ?? {};
174
+ const permissionMode = previous.getPermissionMode?.() ?? previousSlice.permissionMode;
175
+ const nextSlice = {
176
+ ...previousSlice,
177
+ cwd: target.mainRoot,
178
+ workspaceContext: target.workspaceContext,
179
+ projectTrusted: target.projectTrusted,
180
+ ...(permissionMode ? { permissionMode } : {}),
181
+ };
182
+ const built = this.factory(nextSlice);
183
+ if (built === previous) {
184
+ throw new Error(`Session ${sessionId} Engine factory reused the resident owner`);
185
+ }
186
+ candidate = built;
187
+ if (permissionMode && candidate.getPermissionMode?.() !== permissionMode) {
188
+ candidate.setPermissionMode?.(permissionMode);
189
+ }
190
+ const candidateGeneration = this.engineSessionManager(candidate)?.registerSessionGeneration(sessionId) ??
191
+ this.sessionGeneration.get(sessionId) ??
192
+ 1;
193
+ candidate.restoreSessionModel?.(sessionId);
194
+ const workspace = previous.migrateSessionMainRoot(sessionId, target.project, target.mainRoot);
195
+ session.replaceEngine(previous, candidate);
196
+ this.sessionGeneration.set(sessionId, candidateGeneration);
197
+ this.sessionSlices.set(sessionId, nextSlice);
198
+ committed = true;
199
+ await this.disposeEngine(previous, sessionId);
200
+ return workspace;
201
+ }
202
+ catch (error) {
203
+ if (!committed && candidate)
204
+ await this.disposeEngine(candidate, sessionId);
205
+ throw error;
206
+ }
207
+ finally {
208
+ this.residentMigrations.delete(sessionId);
209
+ release();
84
210
  }
85
- return this.getOrCreateNow(sessionId, slice);
86
211
  }
87
212
  /**
88
213
  * Cold-resume a persisted session using its own cwd. The first detached
@@ -129,6 +254,7 @@ export class ChatSessionManager {
129
254
  const sessionManager = this.engineSessionManager(engine);
130
255
  const generation = sessionManager?.registerSessionGeneration(sessionId) ?? 1;
131
256
  this.sessionGeneration.set(sessionId, generation);
257
+ this.sessionSlices.set(sessionId, { ...slice });
132
258
  this.sessions.set(sessionId, session);
133
259
  // A direct user run is an explicit resume/open and may clear the tombstone.
134
260
  // Background wakeups must check isUnavailable() before reaching this path.
@@ -201,6 +327,10 @@ export class ChatSessionManager {
201
327
  return this.closeSession(sessionId, true);
202
328
  }
203
329
  closeSession(sessionId, markClosed) {
330
+ const migration = this.residentMigrations.get(sessionId);
331
+ if (migration) {
332
+ return migration.released.then(() => this.closeSession(sessionId, markClosed));
333
+ }
204
334
  const alreadyClosing = this.closingSessions.get(sessionId);
205
335
  if (alreadyClosing)
206
336
  return alreadyClosing;
@@ -237,6 +367,7 @@ export class ChatSessionManager {
237
367
  else
238
368
  this.closedSessions.delete(sessionId);
239
369
  this.sessionGeneration.delete(sessionId);
370
+ this.sessionSlices.delete(sessionId);
240
371
  };
241
372
  if (!s.isBusy()) {
242
373
  finishClose();
@@ -278,6 +409,9 @@ export class ChatSessionManager {
278
409
  * immediately afterward (the TUI REPL) doesn't orphan detached dev servers.
279
410
  */
280
411
  async closeAllAsync() {
412
+ for (const [sessionId, claim] of [...this.migrationClaims]) {
413
+ this.completeSessionMigration(sessionId, claim.ownershipToken);
414
+ }
281
415
  await Promise.all([...this.sessions.keys()].map((id) => this.close(id)));
282
416
  // App/worker shutdown — reap every background shell so a detached
283
417
  // `npm run dev` doesn't outlive the process as an orphan holding a port
@@ -335,6 +469,35 @@ export class ChatSessionManager {
335
469
  });
336
470
  });
337
471
  }
472
+ async disposeEngine(engine, sessionId) {
473
+ const dispose = engine.dispose;
474
+ if (typeof dispose === "function") {
475
+ try {
476
+ await dispose.call(engine);
477
+ }
478
+ catch (error) {
479
+ logger.warn("chat_session.engine_dispose_failed", {
480
+ sessionId,
481
+ identity: this.identity,
482
+ error: error instanceof Error ? error.message : String(error),
483
+ });
484
+ }
485
+ return;
486
+ }
487
+ const mcpPool = this.runtime.mcpPool;
488
+ if (typeof mcpPool?.unregisterOwner !== "function")
489
+ return;
490
+ try {
491
+ await mcpPool.unregisterOwner(engine);
492
+ }
493
+ catch (error) {
494
+ logger.warn("chat_session.mcp_owner_unregister_failed", {
495
+ sessionId,
496
+ identity: this.identity,
497
+ error: error instanceof Error ? error.message : String(error),
498
+ });
499
+ }
500
+ }
338
501
  engineSessionManager(engine) {
339
502
  const candidate = engine;
340
503
  const manager = candidate.getSessionManager?.();
@@ -21,6 +21,7 @@ export interface TurnOpts {
21
21
  /** Working directory override for this turn. If omitted, Engine uses its
22
22
  * configured cwd. */
23
23
  cwd?: string;
24
+ workspaceContext?: import("../workspace/workspace-context.js").WorkspaceContext;
24
25
  onStream?: (event: StreamEvent) => void;
25
26
  /** User-facing text persisted beside the full model-facing task. */
26
27
  displayText?: string;
@@ -70,7 +71,7 @@ export interface TurnOpts {
70
71
  */
71
72
  export declare class ChatSession {
72
73
  readonly id: string;
73
- readonly engine: Engine;
74
+ private currentEngine;
74
75
  /**
75
76
  * Per-session approval callbacks and resolver-free metadata indexed by requestId.
76
77
  * `readonly` guards the Map reference (preventing reassignment); the
@@ -111,6 +112,13 @@ export declare class ChatSession {
111
112
  private settlePromise;
112
113
  private resolveSettled;
113
114
  constructor(opts: ChatSessionOptions);
115
+ get engine(): Engine;
116
+ /**
117
+ * Swap the Engine at an idle run boundary without replacing the ChatSession
118
+ * that owns the queue, stream callback, approvals, idle timestamp and id.
119
+ * The expected-owner check makes a stale migration fail closed.
120
+ */
121
+ replaceEngine(expected: Engine, replacement: Engine): void;
114
122
  enqueueTurn(task: string, opts: TurnOpts): Promise<EngineResult>;
115
123
  /**
116
124
  * Run session maintenance (for example context-package summarization) under
@@ -7,7 +7,7 @@ import { isSameGoalInstance } from "../goal/lifecycle.js";
7
7
  */
8
8
  export class ChatSession {
9
9
  id;
10
- engine;
10
+ currentEngine;
11
11
  /**
12
12
  * Per-session approval callbacks and resolver-free metadata indexed by requestId.
13
13
  * `readonly` guards the Map reference (preventing reassignment); the
@@ -49,9 +49,26 @@ export class ChatSession {
49
49
  resolveSettled = null;
50
50
  constructor(opts) {
51
51
  this.id = opts.id;
52
- this.engine = opts.engine;
52
+ this.currentEngine = opts.engine;
53
53
  this.defaultOnStream = opts.onStream;
54
54
  }
55
+ get engine() {
56
+ return this.currentEngine;
57
+ }
58
+ /**
59
+ * Swap the Engine at an idle run boundary without replacing the ChatSession
60
+ * that owns the queue, stream callback, approvals, idle timestamp and id.
61
+ * The expected-owner check makes a stale migration fail closed.
62
+ */
63
+ replaceEngine(expected, replacement) {
64
+ if (this.active || this.exclusiveOperation || this.queue.length > 0) {
65
+ throw new Error(`Session ${this.id} is running or has queued turns`);
66
+ }
67
+ if (this.currentEngine !== expected) {
68
+ throw new Error(`Session ${this.id} Engine ownership changed during migration`);
69
+ }
70
+ this.currentEngine = replacement;
71
+ }
55
72
  enqueueTurn(task, opts) {
56
73
  this.lastActivityAt = Date.now();
57
74
  // The user (or a wakeup the guard already let through) is starting a turn —
@@ -283,6 +300,7 @@ export class ChatSession {
283
300
  const onStream = next.opts.onStream ?? this.defaultOnStream;
284
301
  const result = await this.engine.run(next.task, {
285
302
  cwd: next.opts.cwd,
303
+ workspaceContext: next.opts.workspaceContext,
286
304
  sessionId: this.id,
287
305
  displayText: next.opts.displayText,
288
306
  signal: this.controller.signal,
@@ -66,10 +66,18 @@ export interface MobilePermissionModeSnapshotEntry {
66
66
  mode: PermissionMode;
67
67
  }
68
68
  export interface MobileProjectMeta {
69
+ id?: string;
69
70
  path: string;
70
71
  name: string;
71
72
  addedAt?: number;
72
73
  pinned?: boolean;
74
+ roots?: Array<{
75
+ id: string;
76
+ path: string;
77
+ name: string;
78
+ role: "primary" | "secondary";
79
+ }>;
80
+ primaryRootId?: string;
73
81
  }
74
82
  export type MobileImageMime = "image/png" | "image/jpeg" | "image/webp" | "image/gif";
75
83
  export interface MobileImageBase {
@@ -112,6 +120,11 @@ export type MobileClientEvent = {
112
120
  sessionId: string;
113
121
  } | {
114
122
  type: "session.create";
123
+ /** Stable V2 project identity. null explicitly selects the no-repo workspace. */
124
+ projectId?: string | null;
125
+ /** Stable V2 root identity. Omit to use the project's current primary root. */
126
+ rootId?: string;
127
+ /** Legacy client compatibility only; Main validates it against mounted roots. */
115
128
  cwd?: string | null;
116
129
  name?: string;
117
130
  } | {
@@ -230,6 +243,8 @@ export type MobileServerEvent = {
230
243
  type: "chat.accepted";
231
244
  sessionId?: string;
232
245
  cwd?: string | null;
246
+ projectId?: string | null;
247
+ rootId?: string;
233
248
  clientMessageId?: string;
234
249
  attachments?: MobileAttachmentSummary[];
235
250
  } | {
@@ -198,6 +198,7 @@ export declare class AgentServer {
198
198
  */
199
199
  private bgAgentBusUnsubscribe;
200
200
  private readonly wakeupsInFlight;
201
+ private readonly sessionWorkspaceRpc;
201
202
  /**
202
203
  * Effective session manager for the connection this server serves. Without
203
204
  * a resolveIdentity hook this is exactly the host-supplied manager —
@@ -217,10 +218,10 @@ export declare class AgentServer {
217
218
  * Guards:
218
219
  * - chatManager path only (the legacy single-engine / headless path drives
219
220
  * its own loop and has no idle-session-resume concept).
220
- * - Session must exist AND be idle. If it's busy, we do nothing: the in-flight
221
- * run's end-of-turn `drainAll` already collects every pending notification,
222
- * so a second run would be redundant and `enqueueTurn` while busy would
223
- * queue a spurious extra turn.
221
+ * - Session must exist. If it is busy, keep ownership of this wake request and
222
+ * wait for `settled` before draining. Merely returning here loses a wake when
223
+ * the bus event races the run-boundary re-check: both triggers can observe
224
+ * the other as in-flight and leave the result queued forever.
224
225
  * - We `drainAll` exactly here and feed the items into the woken turn. This
225
226
  * also merges a burst of near-simultaneous completions into one wakeup:
226
227
  * the first drains all currently-pending items; subsequent bus events for
@@ -310,8 +311,6 @@ export declare class AgentServer {
310
311
  */
311
312
  private handleBackgroundWork;
312
313
  private handleCloseSession;
313
- private handleReleaseWorkspace;
314
- private handleSetWorkspace;
315
314
  private handleConfigure;
316
315
  /**
317
316
  * Resolve the Engine that owns a session-scoped query (compact, archive_range,
@@ -18,18 +18,20 @@ import { diskDefaultsFrom } from "../engine/engine.js";
18
18
  import { ISOLATED_TASK_BEHAVIOR_MODE } from "../engine/run-types.js";
19
19
  import { isProtectedSettingKey, SettingsManager } from "../settings/manager.js";
20
20
  import { getApprovalRouter, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
21
- import { agentNotificationBus, notificationQueue, buildNotificationMessage, notificationEnvelopeToLegacyStreamEvent, } from "../tool-system/builtin/agent-notifications.js";
21
+ import { agentNotificationBus, notificationQueue, notificationEnvelopeToLegacyStreamEvent, } from "../tool-system/builtin/agent-notifications.js";
22
22
  import { backgroundShellManager } from "../runtime/background-shell.js";
23
23
  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 { wakeSessionForBackgroundResults } from "./background-result-wakeup.js";
27
28
  import { assertSafeSessionId, SessionManager } from "../session/session-manager.js";
28
29
  import { redactLlmConfig, maskSecretValue } from "./redact.js";
29
30
  import { redactSecrets } from "../logging/sanitize-messages.js";
30
31
  import { compileComposition } from "../composition/compiler.js";
31
32
  import { attachProtocolContributions } from "../composition/protocol-attach.js";
32
33
  import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
34
+ import { SessionWorkspaceRpcHandlers } from "./session-workspace-rpc.js";
33
35
  import { describePluginCommands, expandPluginCommandBody, scanPluginCommands, MAX_PLUGIN_COMMAND_ARGUMENT_CHARS, } from "../plugins/pluginCommandsLoader.js";
34
36
  function isValidRunAttachment(value) {
35
37
  if (!value || typeof value !== "object")
@@ -380,6 +382,7 @@ export class AgentServer {
380
382
  */
381
383
  bgAgentBusUnsubscribe = null;
382
384
  wakeupsInFlight = new Set();
385
+ sessionWorkspaceRpc;
383
386
  /**
384
387
  * Effective session manager for the connection this server serves. Without
385
388
  * a resolveIdentity hook this is exactly the host-supplied manager —
@@ -425,6 +428,14 @@ export class AgentServer {
425
428
  throw new Error("AgentServer: either chatManager or engine must be supplied");
426
429
  }
427
430
  this.transport = options.transport;
431
+ this.sessionWorkspaceRpc = new SessionWorkspaceRpcHandlers({
432
+ transport: this.transport,
433
+ getChatManager: () => this.chatManager,
434
+ getLegacyEngine: () => this.legacyEngine,
435
+ getLastSlice: (sessionId) => this.lastSliceBySession.get(sessionId),
436
+ rememberSessionSlice: (sessionId, slice) => this.rememberSessionSlice(sessionId, slice),
437
+ wireInteractiveSession: (session, sessionId) => this.wireInteractiveSession(session, sessionId),
438
+ });
428
439
  // Protocol surface from the compiled composition. Each contributing
429
440
  // module may attach one observer; the server calls them at every
430
441
  // lifecycle hook point and isolates per-observer failures.
@@ -488,22 +499,29 @@ export class AgentServer {
488
499
  // hands us.
489
500
  this.bgAgentBusUnsubscribe = agentNotificationBus.subscribe((envelope) => {
490
501
  const sessionId = envelope.to.sessionId;
491
- const event = notificationEnvelopeToLegacyStreamEvent(envelope);
492
- if (event)
493
- this.notify(Methods.StreamEvent, { sessionId, event });
494
- // Background work that finishes while the session is IDLE (a
502
+ try {
503
+ const event = notificationEnvelopeToLegacyStreamEvent(envelope);
504
+ if (event)
505
+ this.notify(Methods.StreamEvent, { sessionId, event });
506
+ }
507
+ finally {
508
+ // Waking the model is the durable-consumption path; forwarding the
509
+ // legacy UI event is only an observation path. A renderer transport
510
+ // failure after accepting the event must not strand the result in the
511
+ // queue, so schedule the wake from finally.
512
+ if (envelope.kind === "result")
513
+ this.maybeWakeIdleSession(sessionId);
514
+ }
515
+ // Background work that finishes while the session is idle (a
495
516
  // run_in_background Bash like a download, a background sub-agent, or a
496
517
  // video poll — the engine no longer parks on any of them) would otherwise
497
518
  // leave its completion sitting in the queue until the user manually sends.
498
519
  // Wake the session with one run carrying the notification so the model
499
520
  // reads "download complete" and continues on its own (the persisted goal
500
521
  // is judged that turn). If the work finishes while a run is still in
501
- // flight, the idle guard below skips it and the run-boundary re-check
502
- // (trigger B) drains it at end-of-turn instead. A never-exiting dev server
503
- // emits no completion, so it never wakes anything (no task/service
504
- // classification needed).
505
- if (envelope.kind === "result")
506
- this.maybeWakeIdleSession(sessionId);
522
+ // flight, wakeIdleSession waits for that run to settle and then queues the
523
+ // continuation. A never-exiting dev server emits no completion, so it
524
+ // never wakes anything (no task/service classification needed).
507
525
  });
508
526
  // Notify client we're ready
509
527
  this.notify(Methods.Status, { status: "ready" });
@@ -517,10 +535,10 @@ export class AgentServer {
517
535
  * Guards:
518
536
  * - chatManager path only (the legacy single-engine / headless path drives
519
537
  * its own loop and has no idle-session-resume concept).
520
- * - Session must exist AND be idle. If it's busy, we do nothing: the in-flight
521
- * run's end-of-turn `drainAll` already collects every pending notification,
522
- * so a second run would be redundant and `enqueueTurn` while busy would
523
- * queue a spurious extra turn.
538
+ * - Session must exist. If it is busy, keep ownership of this wake request and
539
+ * wait for `settled` before draining. Merely returning here loses a wake when
540
+ * the bus event races the run-boundary re-check: both triggers can observe
541
+ * the other as in-flight and leave the result queued forever.
524
542
  * - We `drainAll` exactly here and feed the items into the woken turn. This
525
543
  * also merges a burst of near-simultaneous completions into one wakeup:
526
544
  * the first drains all currently-pending items; subsequent bus events for
@@ -541,67 +559,22 @@ export class AgentServer {
541
559
  this.maybeWakeIdleSession(sessionId);
542
560
  }
543
561
  })
544
- .catch(() => {
562
+ .catch((error) => {
545
563
  this.wakeupsInFlight.delete(sessionId);
564
+ logger.warn("bg_wakeup.failed", {
565
+ sessionId,
566
+ error: error instanceof Error ? error.message : String(error),
567
+ });
546
568
  });
547
569
  }
548
570
  async wakeIdleSession(sessionId) {
549
- if (!this.chatManager)
550
- return false;
551
- if (this.chatManager.isUnavailable(sessionId))
552
- return false;
553
- const session = this.chatManager.get(sessionId) ?? (await this.rehydrateSessionForWake(sessionId));
554
- if (!session || session.isBusy())
555
- return false;
556
- // Headless / automation runs are one-shot: the caller takes result.text and
557
- // is gone, so there's no consumer for a woken continuation turn. Headless
558
- // already drained its background sub-agents inside engine.run before
559
- // returning; any remaining queued notification (video/shell) must NOT spin
560
- // an orphan turn. Only the interactive path auto-continues.
561
- if (session.engine.isHeadless())
562
- return false;
563
- // Don't resurrect a session the user just Stopped: cancel() leaves it idle
564
- // (active=null) so isBusy() reads false, but auto-running a fresh turn here
565
- // would defeat the Stop. The flag clears the moment the user sends again.
566
- if (session.wasCancelledSinceLastTurn())
567
- return false;
568
- const pending = notificationQueue.drainAll(sessionId);
569
- if (pending.length === 0)
570
- return false;
571
- const task = `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`;
572
- try {
573
- await session.enqueueTurn(task, {
574
- // Synthetic notification, not the user's own input: persisted with an
575
- // `injected` flag so a disk rebuild doesn't render it as a phantom user
576
- // bubble (the live UI shows only the woken assistant's reply).
577
- injected: true,
578
- onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
579
- approvalRouter: this.approvalRouter,
580
- });
581
- }
582
- catch (err) {
583
- // A wakeup turn failing must not crash the bus fan-out. The drained
584
- // notifications are already in the transcript via the run's messages;
585
- // log and move on.
586
- logger.warn("bg_wakeup.turn_failed", {
587
- sessionId,
588
- error: err.message,
589
- });
590
- // Belt-and-braces, mirroring the send() path's run().then(clear-busy):
591
- // the renderer set the composer "working" spinner on this run's
592
- // session_started, and clears it on turn_complete/error. A failure
593
- // BEFORE the turn-loop runs (e.g. a setup error) emits neither, which
594
- // would leave the spinner stuck. Emit a terminal `error` (NOT a
595
- // turn_complete) so the renderer clears busy AND a woken automation
596
- // session's runStatus flips to "failed" rather than being mislabeled
597
- // "completed". If the turn-loop already emitted its own `error`, this is
598
- // a harmless duplicate (busy already cleared, status already failed).
599
- this.notify(Methods.StreamEvent, {
600
- sessionId,
601
- event: { type: "error", error: err?.message ?? "background wakeup failed" },
602
- });
603
- }
604
- return true;
571
+ return wakeSessionForBackgroundResults({
572
+ sessionId,
573
+ manager: this.chatManager,
574
+ rehydrate: (id) => this.rehydrateSessionForWake(id),
575
+ approvalRouter: this.approvalRouter,
576
+ onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
577
+ });
605
578
  }
606
579
  async rehydrateSessionForWake(sessionId) {
607
580
  if (!this.chatManager)
@@ -787,10 +760,16 @@ export class AgentServer {
787
760
  await this.handleCloseSession(req);
788
761
  break;
789
762
  case Methods.ReleaseWorkspace:
790
- this.handleReleaseWorkspace(req);
763
+ this.sessionWorkspaceRpc.release(req);
791
764
  break;
792
765
  case Methods.SetWorkspace:
793
- this.handleSetWorkspace(req);
766
+ this.sessionWorkspaceRpc.set(req);
767
+ break;
768
+ case Methods.MigrateSessionMainRoot:
769
+ await this.sessionWorkspaceRpc.migrateMainRoot(req);
770
+ break;
771
+ case Methods.CompleteSessionMainRootMigration:
772
+ this.sessionWorkspaceRpc.completeMainRootMigration(req);
794
773
  break;
795
774
  case Methods.GoalExtend:
796
775
  this.handleGoalExtend(req);
@@ -1153,6 +1132,7 @@ export class AgentServer {
1153
1132
  }
1154
1133
  const sessionConfig = {
1155
1134
  cwd: params.cwd,
1135
+ workspaceContext: params.workspaceContext,
1156
1136
  projectTrusted: params.projectTrusted,
1157
1137
  preset: params.preset ??
1158
1138
  (params.behaviorMode === ISOLATED_TASK_BEHAVIOR_MODE ? "general" : undefined),
@@ -1226,6 +1206,7 @@ export class AgentServer {
1226
1206
  }
1227
1207
  const run = session.enqueueTurn(params.task, {
1228
1208
  cwd: params.cwd,
1209
+ workspaceContext: params.workspaceContext,
1229
1210
  displayText: displayText || undefined,
1230
1211
  injected: params.injected === true,
1231
1212
  attachments: Array.isArray(params.attachments) ? params.attachments : undefined,
@@ -1268,11 +1249,12 @@ export class AgentServer {
1268
1249
  petWorkDelegation: result.petWorkDelegation,
1269
1250
  };
1270
1251
  this.transport.send(createResponse(req.id, runResult));
1271
- // Run-boundary re-check (trigger B): the session is idle now. If a
1272
- // background task (shell/video) completed DURING this run, its bus event
1273
- // fired while we were busy and trigger A skipped it drain it now and
1274
- // wake a continuation turn. Interactive path only; headless already
1275
- // drained its sub-agents inside engine.run before returning.
1252
+ // Run-boundary re-check (trigger B): normally trigger A already owns a
1253
+ // completion that arrived while this run was busy and is waiting on the
1254
+ // session's `settled` promise. Keep this check as a recovery path for a
1255
+ // missed/delayed bus event and for results committed at the run boundary.
1256
+ // Interactive path only; headless already drained its sub-agents inside
1257
+ // engine.run before returning.
1276
1258
  this.maybeWakeIdleSession(sid);
1277
1259
  }
1278
1260
  catch (err) {
@@ -1358,6 +1340,7 @@ export class AgentServer {
1358
1340
  });
1359
1341
  const result = await this.legacyEngine.run(params.task, {
1360
1342
  cwd: params.cwd,
1343
+ workspaceContext: params.workspaceContext,
1361
1344
  sessionId: params.sessionId,
1362
1345
  displayText: displayText || undefined,
1363
1346
  injected: params.injected === true,
@@ -1944,55 +1927,6 @@ export class AgentServer {
1944
1927
  backgroundJobRegistry.dropForSession(params.sessionId);
1945
1928
  this.transport.send(createResponse(req.id, { ok: true }));
1946
1929
  }
1947
- // ─── ReleaseWorkspace ──────────────────────────────────────────
1948
- handleReleaseWorkspace(req) {
1949
- const params = (req.params ?? {});
1950
- if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
1951
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
1952
- return;
1953
- }
1954
- if (this.chatManager) {
1955
- const session = this.chatManager.get(params.sessionId);
1956
- if (!session) {
1957
- this.transport.send(createResponse(req.id, { ok: true, workspace: null }));
1958
- return;
1959
- }
1960
- const engine = session.engine;
1961
- const workspace = engine.releaseSessionWorkspace?.(params.sessionId) ?? null;
1962
- this.transport.send(createResponse(req.id, { ok: true, workspace }));
1963
- return;
1964
- }
1965
- const engine = this.legacyEngine;
1966
- const workspace = engine?.releaseSessionWorkspace?.(params.sessionId) ?? null;
1967
- this.transport.send(createResponse(req.id, { ok: true, workspace }));
1968
- }
1969
- handleSetWorkspace(req) {
1970
- const params = (req.params ?? {});
1971
- if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
1972
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
1973
- return;
1974
- }
1975
- if (!params.workspace ||
1976
- typeof params.workspace !== "object" ||
1977
- typeof params.workspace.root !== "string" ||
1978
- params.workspace.root.length === 0 ||
1979
- (params.workspace.kind !== "main" && params.workspace.kind !== "worktree")) {
1980
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "valid workspace is required"));
1981
- return;
1982
- }
1983
- const engine = this.chatManager
1984
- ? this.chatManager.get(params.sessionId)?.engine
1985
- : this.legacyEngine;
1986
- if (!engine) {
1987
- this.transport.send(createResponse(req.id, { ok: true, workspace: null }));
1988
- return;
1989
- }
1990
- const workspace = engine.setSessionWorkspace?.(params.sessionId, params.workspace);
1991
- this.transport.send(createResponse(req.id, {
1992
- ok: workspace !== undefined && workspace !== null,
1993
- workspace: workspace ?? null,
1994
- }));
1995
- }
1996
1930
  // ─── Configure ──────────────────────────────────────────────────
1997
1931
  handleConfigure(req) {
1998
1932
  const params = (req.params ?? {});