@cjhyy/code-shell-core 0.9.5 → 0.9.6

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 (48) hide show
  1. package/dist/credentials/access.d.ts +9 -1
  2. package/dist/credentials/access.js +15 -4
  3. package/dist/credentials/store.d.ts +11 -0
  4. package/dist/credentials/store.js +44 -9
  5. package/dist/credentials/types.d.ts +4 -0
  6. package/dist/credentials/types.js +4 -0
  7. package/dist/credentials/use-credential-tool.js +12 -2
  8. package/dist/engine/engine-workspace-authority.js +10 -3
  9. package/dist/engine/engine.d.ts +3 -0
  10. package/dist/engine/engine.js +38 -6
  11. package/dist/engine/prompt-cache-diagnostics.js +10 -1
  12. package/dist/engine/run-goal.js +7 -3
  13. package/dist/engine/run-types.d.ts +16 -0
  14. package/dist/engine/run-workspace.js +5 -21
  15. package/dist/engine/turn-loop.d.ts +4 -4
  16. package/dist/engine/turn-loop.js +29 -9
  17. package/dist/index.d.ts +4 -4
  18. package/dist/index.js +2 -2
  19. package/dist/links/index.d.ts +1 -0
  20. package/dist/links/index.js +1 -0
  21. package/dist/links/link-action-tool.d.ts +2 -1
  22. package/dist/links/link-action-tool.js +80 -44
  23. package/dist/links/status.d.ts +53 -0
  24. package/dist/links/status.js +175 -0
  25. package/dist/llm/prompt-cache.d.ts +35 -3
  26. package/dist/llm/prompt-cache.js +63 -3
  27. package/dist/llm/providers/openai.d.ts +3 -0
  28. package/dist/llm/providers/openai.js +57 -14
  29. package/dist/protocol/background-result-wakeup.d.ts +8 -1
  30. package/dist/protocol/background-result-wakeup.js +76 -38
  31. package/dist/protocol/chat-session-manager.d.ts +7 -1
  32. package/dist/protocol/chat-session-manager.js +44 -8
  33. package/dist/protocol/chat-session.d.ts +2 -0
  34. package/dist/protocol/chat-session.js +4 -1
  35. package/dist/protocol/server.d.ts +3 -0
  36. package/dist/protocol/server.js +148 -43
  37. package/dist/protocol/session-message-result.d.ts +12 -0
  38. package/dist/protocol/session-message-result.js +42 -0
  39. package/dist/protocol/session-message-workspace.d.ts +21 -0
  40. package/dist/protocol/session-message-workspace.js +57 -0
  41. package/dist/protocol/types.d.ts +2 -0
  42. package/dist/session/session-message.d.ts +17 -2
  43. package/dist/tool-system/browser-bridge.d.ts +18 -0
  44. package/dist/tool-system/builtin/agent.js +23 -9
  45. package/dist/tool-system/builtin/browser-tools.js +18 -1
  46. package/dist/tool-system/builtin/index.js +3 -3
  47. package/dist/tool-system/builtin/send-message-to-session.js +19 -3
  48. package/package.json +1 -1
@@ -16,7 +16,7 @@ import { capabilitiesFor } from "../capabilities/index.js";
16
16
  import { clampMaxTokens } from "../clamp-max-tokens.js";
17
17
  import { resolveApiKey, resolveHeaders } from "../provider-auth.js";
18
18
  import { stripVisionFromHistory } from "../strip-vision.js";
19
- import { resolvePromptCachePolicy, uniquePromptCacheBreakpointIndexes, } from "../prompt-cache.js";
19
+ import { createPromptCacheKey, PromptCacheHistory, resolvePromptCachePolicy, uniquePromptCacheBreakpointIndexes, } from "../prompt-cache.js";
20
20
  import { STREAM_WATCHDOG_CONFIG, StreamIdleTimeoutError } from "../stream-watchdog.js";
21
21
  /**
22
22
  * Extract prompt-cache counts from an OpenAI-compatible usage object.
@@ -25,8 +25,8 @@ import { STREAM_WATCHDOG_CONFIG, StreamIdleTimeoutError } from "../stream-watchd
25
25
  * top-level field). Both OpenAI and OpenRouter report this.
26
26
  * - Cache WRITES (first-time prefix ingestion) are reported by OpenRouter as
27
27
  * `prompt_tokens_details.cache_write_tokens` (verified live 2026-07-02).
28
- * OpenAI's automatic caching has no separate write charge and omits it. We
29
- * map it to `cacheCreationTokens` so the UI can show "writing cache" on the
28
+ * Earlier OpenAI models omit it; GPT-5.6+ reports separately billed writes.
29
+ * We map it to `cacheCreationTokens` so the UI can show "writing cache" on the
30
30
  * first turn, not just hits on later turns.
31
31
  *
32
32
  * Returns a spreadable partial so callers omit each key entirely when the API
@@ -163,6 +163,9 @@ export async function runStreamWithWatchdog(stream, opts = {}) {
163
163
  return text;
164
164
  }
165
165
  const MISSING_TOOL_RESULT_WIRE_TEXT = "Error: Tool execution did not complete before the conversation resumed.";
166
+ // Clients are recreated between Engine runs. Keep only bounded, expiring
167
+ // boundary hashes across those recreations; never retain conversation text.
168
+ const sharedPromptCacheHistory = new PromptCacheHistory();
166
169
  /**
167
170
  * OpenAI requires each assistant tool_calls batch to be followed immediately
168
171
  * by exactly one role:tool message per id. Normalize at the provider boundary
@@ -210,6 +213,7 @@ function normalizeOpenAIToolMessagePairs(messages) {
210
213
  }
211
214
  export class OpenAIClient extends LLMClientBase {
212
215
  _client = null;
216
+ promptCacheHistory;
213
217
  dangerouslyAllowBrowser;
214
218
  // Sticky override: once the endpoint tells us `max_tokens` is rejected for
215
219
  // this model, switch to `max_completion_tokens` for the lifetime of the
@@ -228,6 +232,7 @@ export class OpenAIClient extends LLMClientBase {
228
232
  _disablePromptCacheKey = false;
229
233
  constructor(config, defaults, runtimeOptions = {}) {
230
234
  super(config, defaults);
235
+ this.promptCacheHistory = runtimeOptions.promptCacheHistory ?? sharedPromptCacheHistory;
231
236
  this.dangerouslyAllowBrowser = runtimeOptions.dangerouslyAllowBrowser === true;
232
237
  }
233
238
  initClient() {
@@ -305,8 +310,35 @@ export class OpenAIClient extends LLMClientBase {
305
310
  // Per-call reasoning wins; otherwise fall back to provider default
306
311
  // (settings.providers[].reasoning, threaded through LLMConfig).
307
312
  const reasoning = options.reasoning ?? this.config.reasoning;
308
- const messages = this.buildMessages(options.systemPrompt, options.messages, reasoning, options.promptCache);
313
+ const { messages, stablePrefixEndIndex } = this.buildMessages(options.systemPrompt, options.messages, reasoning, options.promptCache);
309
314
  const tools = options.tools?.length ? this.convertTools(options.tools) : undefined;
315
+ const cachePolicy = this.promptCachePolicy(options.promptCache);
316
+ let cachePlan;
317
+ if (cachePolicy.strategy === "openai-hybrid" && options.promptCache?.scopeId) {
318
+ let latestEligibleIndex = -1;
319
+ for (let index = messages.length - 1; index >= 0; index--) {
320
+ const message = messages[index];
321
+ if ((message.role === "user" || message.role === "tool") &&
322
+ (typeof message.content === "string"
323
+ ? message.content.length > 0
324
+ : Array.isArray(message.content) && message.content.length > 0)) {
325
+ latestEligibleIndex = index;
326
+ break;
327
+ }
328
+ }
329
+ // Hash the effective request settings, including tools and passthrough
330
+ // schemas. Transport streaming does not alter the rendered prefix.
331
+ const { messages: _cacheMessages, ...requestIdentity } = this.buildRequestBody(options, messages, tools, reasoning, false);
332
+ cachePlan = this.promptCacheHistory.prepare({
333
+ scopeKey: createPromptCacheKey(options.promptCache.scopeId, JSON.stringify(this.getPromptCacheScopeIdentity())),
334
+ messages,
335
+ latestBoundaryIndex: latestEligibleIndex,
336
+ requestIdentity,
337
+ });
338
+ }
339
+ if (options.promptCache || cachePolicy.strategy === "anthropic-explicit") {
340
+ this.applyPromptCacheBreakpoints(messages, cachePolicy, stablePrefixEndIndex, cachePlan?.readBoundaryIndex);
341
+ }
310
342
  const span = logger.span("llm.request", {
311
343
  cat: "llm",
312
344
  provider: this.provider,
@@ -314,12 +346,15 @@ export class OpenAIClient extends LLMClientBase {
314
346
  stream: !!(options.stream && options.onChunk),
315
347
  messageCount: messages.length,
316
348
  toolCount: tools?.length ?? 0,
317
- cacheStrategy: this.promptCachePolicy(options.promptCache).strategy,
349
+ cacheStrategy: cachePolicy.strategy,
318
350
  });
319
351
  try {
320
352
  const response = options.stream && options.onChunk
321
353
  ? await this.streamMessage(options, messages, tools, reasoning, requestSignal)
322
354
  : await this.nonStreamMessage(options, messages, tools, reasoning, requestSignal);
355
+ if (cachePlan && !requestSignal?.aborted && (response.usage?.promptTokens ?? 0) > 0) {
356
+ this.promptCacheHistory.commit(cachePlan);
357
+ }
323
358
  span.end({
324
359
  stopReason: response.stopReason,
325
360
  promptTokens: response.usage?.promptTokens,
@@ -965,20 +1000,21 @@ export class OpenAIClient extends LLMClientBase {
965
1000
  const stablePrefixEndIndex = stablePrefixEndMessage
966
1001
  ? normalized.indexOf(stablePrefixEndMessage)
967
1002
  : undefined;
968
- const cachePolicy = this.promptCachePolicy(promptCache);
969
- if (promptCache || cachePolicy.strategy === "anthropic-explicit") {
970
- this.applyPromptCacheBreakpoints(normalized, cachePolicy, stablePrefixEndIndex !== undefined && stablePrefixEndIndex >= 0
1003
+ return {
1004
+ messages: normalized,
1005
+ stablePrefixEndIndex: stablePrefixEndIndex !== undefined && stablePrefixEndIndex >= 0
971
1006
  ? stablePrefixEndIndex
972
- : undefined);
973
- }
974
- return normalized;
1007
+ : undefined,
1008
+ };
975
1009
  }
976
1010
  /**
977
1011
  * Translate semantic prefix boundaries to the active wire format. Both
978
1012
  * formats annotate content blocks without reordering messages.
979
1013
  */
980
- applyPromptCacheBreakpoints(messages, policy, stablePrefixEndIndex) {
981
- if (policy.strategy !== "anthropic-explicit" && policy.strategy !== "openai-explicit") {
1014
+ applyPromptCacheBreakpoints(messages, policy, stablePrefixEndIndex, previousBoundaryIndex) {
1015
+ if (policy.strategy !== "anthropic-explicit" &&
1016
+ policy.strategy !== "openai-explicit" &&
1017
+ policy.strategy !== "openai-hybrid") {
982
1018
  return;
983
1019
  }
984
1020
  const markedMessages = new Set();
@@ -1014,7 +1050,14 @@ export class OpenAIClient extends LLMClientBase {
1014
1050
  const requested = uniquePromptCacheBreakpointIndexes([
1015
1051
  policy.breakpoints.includes("system") ? 0 : undefined,
1016
1052
  policy.breakpoints.includes("stable-history") ? stablePrefixEndIndex : undefined,
1017
- policy.breakpoints.includes("rolling-history") ? messages.length - 1 : undefined,
1053
+ // Hybrid uses at most three explicit boundaries (system, stable, prior
1054
+ // successful tail), leaving the fourth write slot for the implicit tail.
1055
+ // Retaining the previous boundary lets this request read the last write.
1056
+ policy.strategy === "openai-hybrid"
1057
+ ? previousBoundaryIndex
1058
+ : policy.breakpoints.includes("rolling-history")
1059
+ ? messages.length - 1
1060
+ : undefined,
1018
1061
  ]);
1019
1062
  for (const index of requested)
1020
1063
  mark(index);
@@ -3,6 +3,11 @@ import { type NotificationQueue } from "../tool-system/builtin/agent-notificatio
3
3
  import type { ApprovalRouter } from "../tool-system/permission.js";
4
4
  import type { ChatSession } from "./chat-session.js";
5
5
  import type { ChatSessionManager } from "./chat-session-manager.js";
6
+ import type { WorkspaceContext } from "../workspace/workspace-context.js";
7
+ interface BackgroundRunWorkspace {
8
+ cwd?: string;
9
+ workspaceContext?: WorkspaceContext;
10
+ }
6
11
  interface BackgroundResultWakeOptions {
7
12
  sessionId: string;
8
13
  manager: ChatSessionManager | null;
@@ -10,11 +15,13 @@ interface BackgroundResultWakeOptions {
10
15
  approvalRouter: ApprovalRouter;
11
16
  onStream(event: StreamEvent): void;
12
17
  notificationMailbox?: NotificationQueue;
18
+ /** Reconstruct fresh host authority for a cold, project-bound Session. */
19
+ resolveWorkspace?(session: ChatSession): Promise<BackgroundRunWorkspace>;
13
20
  }
14
21
  /**
15
22
  * Drain pending background results into exactly one synthetic continuation.
16
23
  * Busy sessions are awaited so a completion cannot fall into the gap between
17
24
  * the notification bus callback and the interactive run-boundary re-check.
18
25
  */
19
- export declare function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, notificationMailbox, }: BackgroundResultWakeOptions): Promise<boolean>;
26
+ export declare function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, notificationMailbox, resolveWorkspace, }: BackgroundResultWakeOptions): Promise<boolean>;
20
27
  export {};
@@ -5,7 +5,7 @@ import { buildNotificationMessage, notificationQueue, } from "../tool-system/bui
5
5
  * Busy sessions are awaited so a completion cannot fall into the gap between
6
6
  * the notification bus callback and the interactive run-boundary re-check.
7
7
  */
8
- export async function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, notificationMailbox = notificationQueue, }) {
8
+ export async function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, notificationMailbox = notificationQueue, resolveWorkspace, }) {
9
9
  if (!manager) {
10
10
  logger.debug("bg_wakeup.skipped", { sessionId, reason: "no_chat_manager" });
11
11
  return false;
@@ -14,54 +14,92 @@ export async function wakeSessionForBackgroundResults({ sessionId, manager, rehy
14
14
  logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_unavailable" });
15
15
  return false;
16
16
  }
17
+ if (notificationMailbox.getSnapshot(sessionId).length === 0) {
18
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "no_pending_results" });
19
+ return false;
20
+ }
17
21
  let session = manager.get(sessionId) ?? (await rehydrate(sessionId));
18
22
  if (!session) {
19
23
  logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_missing" });
20
24
  return false;
21
25
  }
22
- while (session.isBusy()) {
23
- logger.debug("bg_wakeup.waiting_for_idle", {
24
- sessionId,
25
- pendingCount: notificationMailbox.getSnapshot(sessionId).length,
26
- });
27
- await session.settled;
28
- if (manager.isUnavailable(sessionId)) {
29
- logger.debug("bg_wakeup.skipped", {
26
+ let runWorkspace;
27
+ for (;;) {
28
+ if (manager.isUnavailable(sessionId) || manager.get(sessionId) !== session) {
29
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_owner_changed" });
30
+ return false;
31
+ }
32
+ while (session.isBusy()) {
33
+ logger.debug("bg_wakeup.waiting_for_idle", {
30
34
  sessionId,
31
- reason: "session_became_unavailable",
35
+ pendingCount: notificationMailbox.getSnapshot(sessionId).length,
32
36
  });
37
+ await session.settled;
38
+ if (manager.isUnavailable(sessionId)) {
39
+ logger.debug("bg_wakeup.skipped", {
40
+ sessionId,
41
+ reason: "session_became_unavailable",
42
+ });
43
+ return false;
44
+ }
45
+ const current = manager.get(sessionId);
46
+ if (!current) {
47
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_evicted_after_settle" });
48
+ return false;
49
+ }
50
+ session = current;
51
+ }
52
+ // Headless/automation runs are one-shot and have no continuation consumer.
53
+ if (session.engine.isHeadless()) {
54
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "headless" });
33
55
  return false;
34
56
  }
35
- const current = manager.get(sessionId);
36
- if (!current) {
37
- logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_evicted_after_settle" });
57
+ // A user Stop must win over a later background completion.
58
+ if (session.wasCancelledSinceLastTurn()) {
59
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "cancelled_since_last_turn" });
38
60
  return false;
39
61
  }
40
- session = current;
41
- }
42
- // Headless/automation runs are one-shot and have no continuation consumer.
43
- if (session.engine.isHeadless()) {
44
- logger.debug("bg_wakeup.skipped", { sessionId, reason: "headless" });
45
- return false;
46
- }
47
- // A user Stop must win over a later background completion.
48
- if (session.wasCancelledSinceLastTurn()) {
49
- logger.debug("bg_wakeup.skipped", { sessionId, reason: "cancelled_since_last_turn" });
50
- return false;
51
- }
52
- let runWorkspace = {};
53
- try {
54
- const resolver = session.engine.resolveSessionRunWorkspace;
55
- runWorkspace = resolver?.call(session.engine, sessionId) ?? {};
56
- }
57
- catch (error) {
58
- // Resolve before draining so an unavailable authoritative worktree context
59
- // leaves the completion recoverable by a later user run.
60
- logger.warn("bg_wakeup.workspace_unavailable", {
61
- sessionId,
62
- error: error instanceof Error ? error.message : String(error),
63
- });
64
- return false;
62
+ if (notificationMailbox.getSnapshot(sessionId).length === 0) {
63
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "no_pending_results" });
64
+ return false;
65
+ }
66
+ const resolvedEngine = session.engine;
67
+ const settledBeforeResolution = session.settled;
68
+ try {
69
+ if (resolveWorkspace) {
70
+ runWorkspace = await resolveWorkspace(session);
71
+ }
72
+ else {
73
+ const resolver = session.engine.resolveSessionRunWorkspace;
74
+ runWorkspace = resolver?.call(session.engine, sessionId) ?? {};
75
+ }
76
+ }
77
+ catch (error) {
78
+ // Resolve before draining so an unavailable authoritative worktree context
79
+ // leaves the completion recoverable by a later user run.
80
+ logger.warn("bg_wakeup.workspace_unavailable", {
81
+ sessionId,
82
+ error: error instanceof Error ? error.message : String(error),
83
+ });
84
+ return false;
85
+ }
86
+ // Host resolution yields to other turns, Stop, close and root migration.
87
+ // Validate ownership before consuming anything, and never carry a context
88
+ // across an intervening run boundary even when that run already finished.
89
+ if (manager.isUnavailable(sessionId) || manager.get(sessionId) !== session) {
90
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_owner_changed" });
91
+ return false;
92
+ }
93
+ if (session.wasCancelledSinceLastTurn()) {
94
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "cancelled_since_last_turn" });
95
+ return false;
96
+ }
97
+ if (session.isBusy() ||
98
+ session.engine !== resolvedEngine ||
99
+ session.settled !== settledBeforeResolution) {
100
+ continue;
101
+ }
102
+ break;
65
103
  }
66
104
  const pending = notificationMailbox.drainAll(sessionId);
67
105
  if (pending.length === 0) {
@@ -36,6 +36,12 @@ export interface ChatSessionManagerOptions {
36
36
  */
37
37
  dataRoot?: string;
38
38
  }
39
+ export interface GetOrCreateSessionOptions {
40
+ /** Only explicit user opens may clear a closed Session's tombstone. */
41
+ allowReopen?: boolean;
42
+ /** Cancel lifecycle waits without creating or reopening the Session. */
43
+ signal?: AbortSignal;
44
+ }
39
45
  export interface LiveChatSessionSnapshot {
40
46
  generation: number;
41
47
  /** Identity scope of the manager that produced this snapshot. */
@@ -95,7 +101,7 @@ export declare class ChatSessionManager {
95
101
  * identity; each call here builds a fresh instance.
96
102
  */
97
103
  forIdentity(identity: string): ChatSessionManager;
98
- getOrCreate(sessionId: string, slice: EngineConfigSlice): Promise<ChatSession>;
104
+ getOrCreate(sessionId: string, slice: EngineConfigSlice, options?: GetOrCreateSessionOptions): Promise<ChatSession>;
99
105
  /**
100
106
  * Prove whether this worker currently owns a resident Engine, or fence the
101
107
  * Session so Main can perform one durable migration without a re-resume race.
@@ -28,6 +28,27 @@ function assertSafeIdentity(identity) {
28
28
  throw new Error(`invalid identity: unexpected characters: ${identity}`);
29
29
  }
30
30
  }
31
+ function waitForSessionTransition(transition, signal) {
32
+ if (!signal)
33
+ return transition;
34
+ signal.throwIfAborted();
35
+ return new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ const finish = (failed = false, error) => {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ signal.removeEventListener("abort", onAbort);
42
+ if (failed)
43
+ reject(error);
44
+ else
45
+ resolve();
46
+ };
47
+ const onAbort = () => finish(true, signal.reason);
48
+ signal.addEventListener("abort", onAbort, { once: true });
49
+ transition.then(() => finish(), (error) => finish(true, error));
50
+ });
51
+ }
31
52
  export const CLOSED_CHAT_SESSION_TOMBSTONE_LIMIT = 4096;
32
53
  export class ChatSessionManager {
33
54
  sessions = new Map();
@@ -80,29 +101,37 @@ export class ChatSessionManager {
80
101
  engineFactory: (slice) => baseFactory({ ...slice, sessionStorageDir }),
81
102
  });
82
103
  }
83
- async getOrCreate(sessionId, slice) {
104
+ async getOrCreate(sessionId, slice, options = {}) {
105
+ const assertAccess = () => {
106
+ options.signal?.throwIfAborted();
107
+ if (options.allowReopen === false && this.isUnavailable(sessionId)) {
108
+ throw new Error(`target Session is closing or closed: ${sessionId}`);
109
+ }
110
+ };
84
111
  // A non-resident migration claim is a short ownership handoff to Main.
85
112
  // Wait rather than fail the user's run: once Main atomically commits (or
86
113
  // aborts) and releases the token, the Engine is created from current disk.
87
114
  for (;;) {
115
+ assertAccess();
88
116
  const residentMigration = this.residentMigrations.get(sessionId);
89
117
  if (residentMigration) {
90
- await residentMigration.released;
118
+ await waitForSessionTransition(residentMigration.released, options.signal);
91
119
  continue;
92
120
  }
93
121
  const claim = this.migrationClaims.get(sessionId);
94
122
  if (claim) {
95
- await claim.released;
123
+ await waitForSessionTransition(claim.released, options.signal);
96
124
  continue;
97
125
  }
98
126
  const closing = this.closingSessions.get(sessionId);
99
127
  if (closing) {
100
- await closing;
128
+ await waitForSessionTransition(closing, options.signal);
101
129
  continue;
102
130
  }
103
131
  // No await separates the final claim check from getOrCreateNow. On this
104
132
  // process's event loop, either this creates the resident owner first or
105
133
  // beginSessionMigration installs the fence first; both cannot win.
134
+ assertAccess();
106
135
  return this.getOrCreateNow(sessionId, slice);
107
136
  }
108
137
  }
@@ -327,13 +356,20 @@ export class ChatSessionManager {
327
356
  return this.closeSession(sessionId, true);
328
357
  }
329
358
  closeSession(sessionId, markClosed) {
330
- const migration = this.residentMigrations.get(sessionId);
331
- if (migration) {
332
- return migration.released.then(() => this.closeSession(sessionId, markClosed));
333
- }
334
359
  const alreadyClosing = this.closingSessions.get(sessionId);
335
360
  if (alreadyClosing)
336
361
  return alreadyClosing;
362
+ const migration = this.residentMigrations.get(sessionId);
363
+ if (migration) {
364
+ // Publish closing intent before waiting so internal senders cannot
365
+ // acquire the migrated owner ahead of this deferred close operation.
366
+ const closing = migration.released.then(() => {
367
+ this.closingSessions.delete(sessionId);
368
+ return this.closeSession(sessionId, markClosed);
369
+ });
370
+ this.closingSessions.set(sessionId, closing);
371
+ return closing;
372
+ }
337
373
  const s = this.sessions.get(sessionId);
338
374
  if (!s) {
339
375
  if (sessionId.startsWith("qchat-")) {
@@ -28,6 +28,8 @@ export interface TurnOpts {
28
28
  /** Goal mode for this turn — forwarded to engine.run (loop-until-done).
29
29
  * String objective or full GoalConfig (objective + optional budgets). */
30
30
  goal?: string | import("../goal/lifecycle.js").GoalConfig;
31
+ /** Disable all Goal resolution for this standalone turn. */
32
+ disableGoal?: boolean;
31
33
  /** Marks this turn as a synthetic system-reminder injection (background-job
32
34
  * completion notification) rather than the user's own input — persisted so
33
35
  * the disk reader skips it as a user bubble on replay. See Engine.run. */
@@ -138,7 +138,9 @@ export class ChatSession {
138
138
  // Drain queued turns as cancelled
139
139
  const drained = this.queue.splice(0);
140
140
  for (const t of drained) {
141
- t.reject(new Error("cancelled: session aborted before turn ran"));
141
+ t.reject(Object.assign(new Error("cancelled: session aborted before turn ran"), {
142
+ name: "AbortError",
143
+ }));
142
144
  }
143
145
  }
144
146
  isBusy() {
@@ -306,6 +308,7 @@ export class ChatSession {
306
308
  signal: this.controller.signal,
307
309
  onStream,
308
310
  goal: next.opts.goal,
311
+ disableGoal: next.opts.disableGoal,
309
312
  injected: next.opts.injected,
310
313
  clientMessageId: next.opts.clientMessageId,
311
314
  archiveBeforeCurrentTurn: next.opts.archiveBeforeCurrentTurn,
@@ -371,6 +371,9 @@ export declare class AgentServer {
371
371
  private makePanelBridge;
372
372
  private requestPanelActionForSession;
373
373
  private requestWorkspaceSwitchForSession;
374
+ /** The same host authority resolves both dispatched turns and their reply wakeups. */
375
+ private resolveHostSessionWorkspace;
376
+ private requestWorkspaceActionForSession;
374
377
  /**
375
378
  * Ask the client to answer a question from the agent (legacy single-engine
376
379
  * path). This intentionally has no wall-clock timeout; Stop/cancel drains