@cjhyy/code-shell-core 0.8.20 → 0.9.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 (65) 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 +35 -0
  9. package/dist/engine/engine-workspace-authority.js +136 -0
  10. package/dist/engine/engine.d.ts +5 -10
  11. package/dist/engine/engine.js +84 -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/chat-session-manager.d.ts +42 -1
  40. package/dist/protocol/chat-session-manager.js +167 -4
  41. package/dist/protocol/chat-session.d.ts +9 -1
  42. package/dist/protocol/chat-session.js +20 -2
  43. package/dist/protocol/mobile-remote-types.d.ts +15 -0
  44. package/dist/protocol/server.d.ts +1 -2
  45. package/dist/protocol/server.js +21 -51
  46. package/dist/protocol/session-workspace-rpc.d.ts +23 -0
  47. package/dist/protocol/session-workspace-rpc.js +171 -0
  48. package/dist/protocol/types.d.ts +45 -1
  49. package/dist/protocol/types.js +4 -0
  50. package/dist/session/session-manager.d.ts +18 -0
  51. package/dist/session/session-manager.js +87 -0
  52. package/dist/tool-system/builtin/cron.d.ts +11 -0
  53. package/dist/tool-system/builtin/cron.js +27 -2
  54. package/dist/tool-system/builtin/edit.js +5 -3
  55. package/dist/tool-system/builtin/write.js +5 -3
  56. package/dist/tool-system/context.d.ts +4 -1
  57. package/dist/tool-system/executor.js +1 -1
  58. package/dist/tool-system/path-policy.d.ts +11 -3
  59. package/dist/tool-system/path-policy.js +56 -36
  60. package/dist/types.d.ts +6 -0
  61. package/dist/workspace/canonical-key.d.ts +7 -0
  62. package/dist/workspace/canonical-key.js +39 -0
  63. package/dist/workspace/workspace-context.d.ts +26 -0
  64. package/dist/workspace/workspace-context.js +112 -0
  65. package/package.json +1 -1
@@ -1,4 +1,6 @@
1
1
  import { resolveRunProfileState } from "./run-setup.js";
2
+ import { canonicalKey } from "../workspace/canonical-key.js";
3
+ import { legacySingleRootWorkspace, validateWorkspaceContext, workspacePrimaryRoot, } from "../workspace/workspace-context.js";
2
4
  /**
3
5
  * Resolve the working directory for a run. Precedence for legacy sessions:
4
6
  * options.cwd > resumed session's state.cwd > config.cwd > process.cwd()
@@ -93,6 +95,37 @@ export async function resolveRunWorkspace(args) {
93
95
  configCwd: args.configCwd,
94
96
  processCwd: args.processCwd,
95
97
  });
98
+ const rawWorkspaceContext = options?.workspaceContext ?? args.configWorkspaceContext;
99
+ let workspaceContext;
100
+ try {
101
+ workspaceContext = rawWorkspaceContext
102
+ ? validateWorkspaceContext(rawWorkspaceContext)
103
+ : legacySingleRootWorkspace(cwd);
104
+ }
105
+ catch (error) {
106
+ return workspaceError(options?.sessionId, error instanceof Error ? error.message : String(error));
107
+ }
108
+ const authoritativeWorkspaceContext = rawWorkspaceContext !== undefined;
109
+ const primary = workspacePrimaryRoot(workspaceContext);
110
+ if (canonicalKey(primary.path) !== canonicalKey(cwd)) {
111
+ return workspaceError(options?.sessionId, `WorkspaceContext primary does not match effective cwd: ${primary.path} != ${cwd}`);
112
+ }
113
+ if (workspaceResume?.ok &&
114
+ workspaceResume.reason !== "legacy" &&
115
+ canonicalKey(workspaceResume.workspace.root) !== canonicalKey(primary.path)) {
116
+ return workspaceError(options?.sessionId, "WorkspaceContext primary does not match SessionWorkspace");
117
+ }
118
+ const binding = options?.sessionId
119
+ ? args.sessionManager.readSessionProjectBinding(options.sessionId)
120
+ : undefined;
121
+ if (binding && !authoritativeWorkspaceContext) {
122
+ return workspaceError(options?.sessionId, "bound Session requires an authoritative WorkspaceContext");
123
+ }
124
+ if (binding &&
125
+ (binding.projectId !== workspaceContext.projectId ||
126
+ binding.mainRootId !== workspaceContext.sessionMainRootId)) {
127
+ return workspaceError(options?.sessionId, "WorkspaceContext does not match persisted project binding");
128
+ }
96
129
  const profileState = profile?.disableWorkspaceProfile
97
130
  ? {
98
131
  workspaceProfile: undefined,
@@ -114,7 +147,21 @@ export async function resolveRunWorkspace(args) {
114
147
  runPermissionMode,
115
148
  runPlanMode,
116
149
  cwd,
150
+ workspaceContext,
151
+ authoritativeWorkspaceContext,
117
152
  profileState,
118
153
  },
119
154
  };
120
155
  }
156
+ function workspaceError(sessionId, message) {
157
+ return {
158
+ ok: false,
159
+ result: {
160
+ text: `ERROR: ${message}`,
161
+ reason: "completed",
162
+ sessionId: sessionId ?? "workspace-invalid",
163
+ turnCount: 0,
164
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
165
+ },
166
+ };
167
+ }
@@ -21,6 +21,7 @@ export interface CreateSubAgentSpawnerDeps {
21
21
  parentSandbox: SandboxConfig;
22
22
  presetName: AgentPresetName;
23
23
  cwd: string;
24
+ workspaceContext?: import("../workspace/workspace-context.js").WorkspaceContext;
24
25
  permissionMode: NonNullable<EngineConfig["permissionMode"]>;
25
26
  modelPool?: ModelPool;
26
27
  appendParentSubagent: (agentId: string, description: string) => void;
@@ -233,6 +233,7 @@ export function createSubAgentSpawner(deps) {
233
233
  retryMaxAttempts: 2,
234
234
  },
235
235
  cwd: deps.cwd,
236
+ workspaceContext: deps.workspaceContext ?? deps.parentConfig.workspaceContext,
236
237
  permissionMode: deps.permissionMode,
237
238
  preset: deps.presetName,
238
239
  enabledBuiltinTools: scope.enabled,
@@ -33,6 +33,8 @@ export interface EngineConfig {
33
33
  */
34
34
  clientDefaults?: ClientDefaults;
35
35
  cwd?: string;
36
+ /** Trusted host-provided root authorization for runs created by this Engine. */
37
+ workspaceContext?: import("../workspace/workspace-context.js").WorkspaceContext;
36
38
  maxTurns?: number;
37
39
  /**
38
40
  * Override the goal-mode consecutive-stop-block cap (TODO 3.1). Falls back to
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.8.20";
7
- export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
6
+ export declare const VERSION = "0.9.0";
7
+ export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionProjectBinding, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
9
9
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, CompositionError, SandboxUnavailableError, } from "./exceptions.js";
10
10
  export { compileComposition, toCompositionSnapshot, computeCompositionDigest, compositionToolCatalog, resolvePresetFromComposition, } from "./composition/index.js";
@@ -62,7 +62,7 @@ export { AgentClient, type BackgroundAgentCompletedHandler } from "./protocol/cl
62
62
  export { createInProcessTransport, StdioTransport, type Transport } from "./protocol/transport.js";
63
63
  export { SocketTransport, listenTcp, type TcpListenResult } from "./protocol/tcp-transport.js";
64
64
  export { createServer, createClient, type CreateServerOptions, type CreateClientOptions, type ServerHandle, } from "./protocol/factories.js";
65
- export { Methods, ErrorCodes, type RpcMessage, type RunResult, type ForkSessionParams, type ForkSessionResult as ProtocolForkSessionResult, } from "./protocol/types.js";
65
+ export { Methods, ErrorCodes, type RpcMessage, type RunResult, type ForkSessionParams, type ForkSessionResult as ProtocolForkSessionResult, type MigrateSessionMainRootResult, } from "./protocol/types.js";
66
66
  export type * from "./protocol/mobile-remote-types.js";
67
67
  export { Transcript } from "./session/transcript.js";
68
68
  export { SessionManager, sessionMainRoot, codeShellHome, sessionsRoot, buildForkState, buildForkTranscript, type ForkSessionOptions, type ForkSessionResult, } from "./session/session-manager.js";
@@ -26,7 +26,7 @@ export type { ExposureRationale } from "./tool-system/external-tool-exposure.js"
26
26
  export { webSearchTool } from "./tool-system/builtin/web-search.js";
27
27
  export { webFetchTool } from "./tool-system/builtin/web-fetch.js";
28
28
  export { extractJSON, extractJSONArray } from "./utils/json.js";
29
- export type { ExtensionQueryHandler, ExtensionTool, } from "./tool-system/capability-module.js";
29
+ export type { ExtensionQueryHandler, ExtensionTool } from "./tool-system/capability-module.js";
30
30
  export type { SessionWorkspace } from "./types.js";
31
31
  export type { CapabilityArtifactDetector, CapabilityDynamicContextProvider, CapabilityToolServiceHost, } from "./capabilities/index.js";
32
32
  export type { AgentModule, AgentEngineContributions, AgentProtocolContributions, AgentModuleToolContribution, ResolvedComposition, } from "./composition/types.js";
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export { getGraphemeSegmenter, firstGrapheme, lastGrapheme, getWordSegmenter, getRelativeTimeFormat, getTimeZone, getSystemLocaleLanguage, } from "./utils/intl.js";
9
9
  export { env } from "./utils/env.js";
10
+ export { canonicalKey, canonicalPath, computeWorkspaceRootsDigest, createWorkspaceContext, legacySingleRootWorkspace, validateWorkspaceContext, workspacePrimaryRoot, type ProjectId, type ProjectRootContext, type ProjectRootId, type WorkspaceContext, type WorkspaceContextInput, } from "./workspace/workspace-context.js";
10
11
  export { default as sliceAnsi } from "./utils/sliceAnsi.js";
11
12
  export { execFileNoThrow } from "./utils/execFileNoThrow.js";
12
13
  export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, resolveGitPath, } from "./utils/exec.js";
@@ -27,7 +28,6 @@ export { getInteractiveApprovalBackend } from "./tool-system/permission.js";
27
28
  export { defaultSandboxConfig, type SandboxConfig } from "./tool-system/sandbox/index.js";
28
29
  export { buildNotificationMessage, buildNotificationSummary, notificationQueue, agentNotificationBus, notificationItemToStreamEvent, type NotificationItem, } from "./tool-system/builtin/agent-notifications.js";
29
30
  export { backgroundJobRegistry } from "./tool-system/builtin/background-jobs.js";
30
- export type { BackgroundAgentCompletedEvent } from "./types.js";
31
31
  export { startAutomation, type StartAutomationDeps, type AutomationHandle, CronScheduler, cronScheduler, type CronExecutionOutcome, type CronJob, type CronJobLifecycleEvent, type CronPermissionLevel, type CronTemplateSource, type CreateJobOptions, type UpdateJobPatch, CronStore, defaultCronStorePath, bindCronToEngine, bindCronToRunManager, type CronRunner, type CronRunRequest, type CronRunResult, type RunSubmitter, isCronExpression, parseCronExpression, nextCronTime, validateSchedule, type ParsedCron, resolveWritePolicy, wrapUntrustedInput, type WritePolicy, runWriteJobInWorktree, type WriteJobGitOps, type RunWriteJobInput, type RunWriteJobResult, } from "./automation/index.js";
32
32
  export { asyncAgentRegistry, type AsyncAgentEntry } from "./tool-system/builtin/agent-registry.js";
33
33
  export { backgroundShellManager, BackgroundShellManager, type BgShell, type BgShellStatus, } from "./runtime/background-shell.js";
@@ -46,7 +46,7 @@ export { capabilitiesFor, type Capability } from "./llm/capabilities/index.js";
46
46
  export { reasoningControlFor, type ReasoningControl, } from "./llm/capabilities/reasoning-control.js";
47
47
  export { REASONING_EFFORTS, type ReasoningSetting } from "./llm/reasoning-setting.js";
48
48
  export { type ProviderConfig } from "./llm/provider-catalog.js";
49
- export type { ApprovalRequest, ApprovalResult, ApprovalScope, TaskInfo } from "./types.js";
49
+ export type { ApprovalRequest, ApprovalResult, ApprovalScope, BackgroundAgentCompletedEvent, TaskInfo, } from "./types.js";
50
50
  export { fileCache } from "./tool-system/builtin/file-cache.js";
51
51
  export { validateToolArgs } from "./tool-system/validation.js";
52
52
  export { createOffBackend } from "./tool-system/sandbox/off.js";
@@ -8,6 +8,7 @@
8
8
  // ─── Utils (shared primitives used by TUI) ───────────────────────
9
9
  export { getGraphemeSegmenter, firstGrapheme, lastGrapheme, getWordSegmenter, getRelativeTimeFormat, getTimeZone, getSystemLocaleLanguage, } from "./utils/intl.js";
10
10
  export { env } from "./utils/env.js";
11
+ export { canonicalKey, canonicalPath, computeWorkspaceRootsDigest, createWorkspaceContext, legacySingleRootWorkspace, validateWorkspaceContext, workspacePrimaryRoot, } from "./workspace/workspace-context.js";
11
12
  export { default as sliceAnsi } from "./utils/sliceAnsi.js";
12
13
  export { execFileNoThrow } from "./utils/execFileNoThrow.js";
13
14
  export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, resolveGitPath, } from "./utils/exec.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.8.20";
6
+ export const VERSION = "0.9.0";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, CompositionError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Composition (AgentModule / ResolvedComposition) ─────────────
@@ -6,6 +6,8 @@ export interface InstantiatePluginAutomationTemplateOptions {
6
6
  templateId: string;
7
7
  expectedRevision: string;
8
8
  cwd?: string;
9
+ projectId?: string;
10
+ rootId?: string;
9
11
  /** Effective project/global disable list computed by the host for this cwd. */
10
12
  disabledPluginNames: ReadonlySet<string>;
11
13
  maxJobs?: number;
@@ -38,6 +38,10 @@ export function instantiatePluginAutomationTemplate(options) {
38
38
  const template = contribution.template;
39
39
  return options.scheduler.create(template.title.default, template.schedule, template.prompt, {
40
40
  ...(template.workspace === "current" && options.cwd ? { cwd: options.cwd } : {}),
41
+ ...(template.workspace === "current" && options.projectId
42
+ ? { projectId: options.projectId }
43
+ : {}),
44
+ ...(template.workspace === "current" && options.rootId ? { rootId: options.rootId } : {}),
41
45
  ...(template.timezone ? { timezone: template.timezone } : {}),
42
46
  permissionLevel: template.permissionLevel,
43
47
  templateSource: {
@@ -10,8 +10,11 @@ import { type ScanOptions } from "./instruction-scanner.js";
10
10
  import { type AgentPreset } from "../preset/index.js";
11
11
  import type { BuiltinTool } from "../tool-system/builtin/index.js";
12
12
  import type { CapabilityDynamicContextProvider } from "../capabilities/index.js";
13
+ import { type WorkspaceContext } from "../workspace/workspace-context.js";
13
14
  export interface ComposerOptions {
14
15
  cwd: string;
16
+ /** Run workspace; omitted by legacy callers that only provide cwd. */
17
+ workspace?: WorkspaceContext;
15
18
  model: string;
16
19
  instructionOptions?: ScanOptions;
17
20
  /** Resolved preset — used to load section-based prompt. */
@@ -106,9 +109,9 @@ export interface ComposerOptions {
106
109
  disableSourcesContext?: boolean;
107
110
  }
108
111
  export declare class PromptComposer {
109
- private readonly options;
110
112
  private sectionCache;
111
113
  private cachedInstructions;
114
+ private readonly options;
112
115
  constructor(options: ComposerOptions);
113
116
  /**
114
117
  * Build the system prompt from sections.
@@ -11,12 +11,18 @@ import { MemoryManager } from "../session/memory.js";
11
11
  import { scanSkills } from "../skills/index.js";
12
12
  import { buildSkillListing } from "../tool-system/builtin/skill-prompt.js";
13
13
  import { resolveAgentPreset, buildPresetSystemPrompt } from "../preset/index.js";
14
+ import { legacySingleRootWorkspace, validateWorkspaceContext, } from "../workspace/workspace-context.js";
14
15
  export class PromptComposer {
15
- options;
16
16
  sectionCache = new SectionCache();
17
17
  cachedInstructions = null;
18
+ options;
18
19
  constructor(options) {
19
- this.options = options;
20
+ this.options = {
21
+ ...options,
22
+ workspace: options.workspace === undefined
23
+ ? legacySingleRootWorkspace(options.cwd)
24
+ : validateWorkspaceContext(options.workspace),
25
+ };
20
26
  }
21
27
  /**
22
28
  * Build the system prompt from sections.
@@ -58,7 +64,7 @@ export class PromptComposer {
58
64
  const preset = this.options.preset ?? resolveAgentPreset();
59
65
  const parts = await Promise.all((this.options.dynamicContextProviders ?? []).map(async (provider) => {
60
66
  try {
61
- return await provider({ cwd: this.options.cwd, preset });
67
+ return await provider({ cwd: this.options.cwd, workspace: this.options.workspace, preset });
62
68
  }
63
69
  catch {
64
70
  // A capability's optional context must not make a turn fail.
@@ -164,6 +170,8 @@ export class PromptComposer {
164
170
  const lines = [
165
171
  `You are an AI agent powered by ${this.options.model}.`,
166
172
  `Working directory: ${this.options.cwd}`,
173
+ `Workspace roots (${this.options.workspace.roots.length}):`,
174
+ ...this.options.workspace.roots.map((root) => `- [${root.role}] ${root.path} (rootId: ${root.id})`),
167
175
  `Platform: ${process.platform}`,
168
176
  `Shell: ${process.env.SHELL ?? "unknown"}`,
169
177
  ];
@@ -1,9 +1,10 @@
1
1
  import { ChatSession } from "./chat-session.js";
2
2
  import type { SessionKind } from "../types.js";
3
+ import type { SessionProjectBinding, SessionWorkspace } from "../types.js";
3
4
  import type { Engine } from "../engine/engine.js";
4
5
  import type { EngineRuntime } from "../engine/runtime.js";
5
6
  import type { EngineConfig } from "../engine/types.js";
6
- export type EngineConfigSlice = Pick<EngineConfig, "permissionMode" | "preset" | "customSystemPrompt" | "appendSystemPrompt" | "goal" | "maxTurns" | "maxContextTokens" | "cwd" | "projectTrusted" | "sessionStorageDir">;
7
+ export type EngineConfigSlice = Pick<EngineConfig, "permissionMode" | "preset" | "customSystemPrompt" | "appendSystemPrompt" | "goal" | "maxTurns" | "maxContextTokens" | "cwd" | "workspaceContext" | "projectTrusted" | "sessionStorageDir">;
7
8
  /** Identity scope every ChatSessionManager belongs to when none is injected. */
8
9
  export declare const LOCAL_CHAT_IDENTITY = "local";
9
10
  export interface ChatSessionManagerOptions {
@@ -48,11 +49,30 @@ export interface LiveChatSessionSnapshot {
48
49
  }>;
49
50
  }
50
51
  export declare const CLOSED_CHAT_SESSION_TOMBSTONE_LIMIT = 4096;
52
+ export interface ResidentSessionMainRootMigration {
53
+ project: SessionProjectBinding;
54
+ mainRoot: string;
55
+ workspaceContext: import("../workspace/workspace-context.js").WorkspaceContext;
56
+ projectTrusted: boolean;
57
+ }
58
+ export type SessionMigrationOwnership = {
59
+ status: "resident";
60
+ session: ChatSession;
61
+ } | {
62
+ status: "not-resident";
63
+ ownershipToken: string;
64
+ } | {
65
+ status: "failed";
66
+ error: string;
67
+ };
51
68
  export declare class ChatSessionManager {
52
69
  private readonly sessions;
53
70
  private readonly closingSessions;
54
71
  private readonly closedSessions;
55
72
  private readonly sessionGeneration;
73
+ private readonly sessionSlices;
74
+ private readonly migrationClaims;
75
+ private readonly residentMigrations;
56
76
  readonly runtime: EngineRuntime;
57
77
  /** Identity scope this manager serves ("local" unless injected). */
58
78
  readonly identity: string;
@@ -76,6 +96,26 @@ export declare class ChatSessionManager {
76
96
  */
77
97
  forIdentity(identity: string): ChatSessionManager;
78
98
  getOrCreate(sessionId: string, slice: EngineConfigSlice): Promise<ChatSession>;
99
+ /**
100
+ * Prove whether this worker currently owns a resident Engine, or fence the
101
+ * Session so Main can perform one durable migration without a re-resume race.
102
+ */
103
+ beginSessionMigration(sessionId: string, ownershipToken: string): SessionMigrationOwnership;
104
+ /** Release only the exact claim minted for this Session. */
105
+ completeSessionMigration(sessionId: string, ownershipToken: string): boolean;
106
+ /**
107
+ * Rebuild an idle resident Engine against a new authoritative main root.
108
+ *
109
+ * Transaction order is intentional:
110
+ * 1. construct and restore the candidate while durable state still points at
111
+ * the old owner (factory/restore failure is therefore a no-op),
112
+ * 2. atomically commit durable state through the old owning Engine,
113
+ * 3. synchronously swap the Engine inside the existing ChatSession,
114
+ * 4. dispose the unreachable old Engine before reporting success.
115
+ *
116
+ * No await occurs between the idle check, durable commit and owner swap.
117
+ */
118
+ migrateResidentSessionMainRoot(sessionId: string, target: ResidentSessionMainRootMigration): Promise<SessionWorkspace>;
79
119
  /**
80
120
  * Cold-resume a persisted session using its own cwd. The first detached
81
121
  * Engine is only a storage probe; all actual work runs on the second Engine
@@ -121,6 +161,7 @@ export declare class ChatSessionManager {
121
161
  startIdleSweeper(intervalMs?: number): void;
122
162
  stopIdleSweeper(): void;
123
163
  private unregisterMcpOwner;
164
+ private disposeEngine;
124
165
  private engineSessionManager;
125
166
  }
126
167
  /**
@@ -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,