@cjhyy/code-shell-core 0.9.0 → 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.
@@ -2,12 +2,27 @@ import type { SessionProjectBinding, SessionState, SessionWorkspace } from "../t
2
2
  import { type SessionManager } from "../session/session-manager.js";
3
3
  import type { SessionMessageRouter, SessionMessageToolService } from "../session/session-message.js";
4
4
  import { type WorkspaceContext } from "../workspace/workspace-context.js";
5
+ export interface SyntheticRunWorkspace {
6
+ cwd: string;
7
+ workspaceContext?: WorkspaceContext;
8
+ }
5
9
  export declare class SessionWorkspaceAuthorityTracker {
6
10
  private readonly contexts;
7
11
  remember(sessionId: string, context: WorkspaceContext): void;
12
+ get(sessionId: string): WorkspaceContext | undefined;
8
13
  delete(sessionId: string): void;
9
14
  clear(): void;
10
15
  }
16
+ /**
17
+ * Reconstruct authoritative options for a core-originated continuation.
18
+ * Interactive turns carry a fresh WorkspaceContext from their host, whereas
19
+ * background-result wakeups must rebase the last trusted context onto the
20
+ * SessionWorkspace persisted after a worktree switch.
21
+ */
22
+ export declare function resolveSyntheticRunWorkspace(sessionManager: SessionManager, authorities: SessionWorkspaceAuthorityTracker, config: {
23
+ cwd?: string;
24
+ workspaceContext?: WorkspaceContext;
25
+ }, sessionId: string): SyntheticRunWorkspace;
11
26
  export declare function createAuthorizedSessionMessageService(options: {
12
27
  sessionManager: SessionManager;
13
28
  router: SessionMessageRouter | undefined;
@@ -1,7 +1,7 @@
1
1
  import { assertSafeSessionId } from "../session/session-manager.js";
2
2
  import { clearSessionPathApprovalsUnderRoot } from "../tool-system/path-policy.js";
3
3
  import { canonicalKey } from "../workspace/canonical-key.js";
4
- import { removedWorkspaceRootPaths, } from "../workspace/workspace-context.js";
4
+ import { rebaseWorkspacePrimaryRoot, removedWorkspaceRootPaths, } from "../workspace/workspace-context.js";
5
5
  export class SessionWorkspaceAuthorityTracker {
6
6
  contexts = new Map();
7
7
  remember(sessionId, context) {
@@ -13,6 +13,9 @@ export class SessionWorkspaceAuthorityTracker {
13
13
  }
14
14
  this.contexts.set(sessionId, context);
15
15
  }
16
+ get(sessionId) {
17
+ return this.contexts.get(sessionId);
18
+ }
16
19
  delete(sessionId) {
17
20
  this.contexts.delete(sessionId);
18
21
  }
@@ -20,6 +23,34 @@ export class SessionWorkspaceAuthorityTracker {
20
23
  this.contexts.clear();
21
24
  }
22
25
  }
26
+ /**
27
+ * Reconstruct authoritative options for a core-originated continuation.
28
+ * Interactive turns carry a fresh WorkspaceContext from their host, whereas
29
+ * background-result wakeups must rebase the last trusted context onto the
30
+ * SessionWorkspace persisted after a worktree switch.
31
+ */
32
+ export function resolveSyntheticRunWorkspace(sessionManager, authorities, config, sessionId) {
33
+ const workspace = sessionManager.getSessionWorkspace(sessionId);
34
+ const cwd = workspace?.root ?? sessionManager.readSessionMainRoot(sessionId) ?? config.cwd ?? process.cwd();
35
+ const binding = sessionManager.readSessionProjectBinding(sessionId);
36
+ const baseContext = authorities.get(sessionId) ?? config.workspaceContext;
37
+ // A legacy Session with no host authority must stay legacy. Passing a
38
+ // synthesized prior-run context would persist a spurious project binding.
39
+ if (!binding && !config.workspaceContext)
40
+ return { cwd };
41
+ if (!baseContext) {
42
+ throw new Error(`Session ${sessionId} has a project binding but no WorkspaceContext`);
43
+ }
44
+ if (binding &&
45
+ (binding.projectId !== baseContext.projectId ||
46
+ binding.mainRootId !== baseContext.sessionMainRootId)) {
47
+ throw new Error(`Session ${sessionId} WorkspaceContext does not match its project binding`);
48
+ }
49
+ return {
50
+ cwd,
51
+ workspaceContext: rebaseWorkspacePrimaryRoot(baseContext, cwd),
52
+ };
53
+ }
23
54
  export function createAuthorizedSessionMessageService(options) {
24
55
  const { sessionManager, router, sourceSessionId } = options;
25
56
  const sourceRoot = sessionManager.readSessionMainRoot(sourceSessionId);
@@ -10,6 +10,7 @@ import { type GoalConfig, type GoalExtension } from "../goal/lifecycle.js";
10
10
  import { type CompactStrategy } from "../context/manager.js";
11
11
  import { SessionManager, type ForkSessionOptions, type ForkSessionResult, type SummaryForkOptions } from "../session/session-manager.js";
12
12
  import type { SessionMessageRouter } from "../session/session-message.js";
13
+ import { type SyntheticRunWorkspace } from "./engine-workspace-authority.js";
13
14
  import type { AskUserFn } from "../tool-system/builtin/ask-user.js";
14
15
  import type { CapabilityOverride, CapabilityOverrides } from "../settings/schema.js";
15
16
  import { type FeatureFlagName } from "../settings/feature-flags.js";
@@ -495,6 +496,7 @@ export declare class Engine {
495
496
  getCurrentModel(): string;
496
497
  getHookRegistry(): HookRegistry;
497
498
  getSessionManager(): SessionManager;
499
+ resolveSessionRunWorkspace(sessionId: string): SyntheticRunWorkspace;
498
500
  /**
499
501
  * Apply a field-level disk update and rebase this Engine's matching live
500
502
  * bundle onto the returned revision so its next whole-state CAS can proceed.
@@ -23,7 +23,7 @@ import { ContextManager } from "../context/manager.js";
23
23
  import { CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS, buildAnchoredSummaryMessage, buildContextPackagePromptFromSerialized, estimateTokens, groupMessagesByApiRound, serializeContextPackageMessages, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
24
24
  import { PromptComposer } from "../prompt/composer.js";
25
25
  import { SessionManager, isEphemeralSessionState, sessionsRoot, } from "../session/session-manager.js";
26
- import { createAuthorizedSessionMessageService, migrateOwnedSessionMainRoot, releaseOwnedSessionWorkspace, SessionWorkspaceAuthorityTracker, setOwnedSessionWorkspace, } from "./engine-workspace-authority.js";
26
+ import { createAuthorizedSessionMessageService, migrateOwnedSessionMainRoot, releaseOwnedSessionWorkspace, resolveSyntheticRunWorkspace, SessionWorkspaceAuthorityTracker, setOwnedSessionWorkspace, } from "./engine-workspace-authority.js";
27
27
  import { createRunUsageAccounting, wireRunModelFacade } from "./run-accounting.js";
28
28
  import { logger, runWithSid } from "../logging/logger.js";
29
29
  import { recordSessionStart } from "../logging/session-recorder.js";
@@ -2404,6 +2404,9 @@ export class Engine {
2404
2404
  getSessionManager() {
2405
2405
  return this.sessionManager;
2406
2406
  }
2407
+ resolveSessionRunWorkspace(sessionId) {
2408
+ return resolveSyntheticRunWorkspace(this.sessionManager, this.workspaceAuthorities, this.config, sessionId);
2409
+ }
2407
2410
  /**
2408
2411
  * Apply a field-level disk update and rebase this Engine's matching live
2409
2412
  * bundle onto the returned revision so its next whole-state CAS can proceed.
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.9.0";
6
+ export declare const VERSION = "0.9.1";
7
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";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.9.0";
6
+ export const VERSION = "0.9.1";
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) ─────────────
@@ -0,0 +1,18 @@
1
+ import type { StreamEvent } from "../types.js";
2
+ import type { ApprovalRouter } from "../tool-system/permission.js";
3
+ import type { ChatSession } from "./chat-session.js";
4
+ import type { ChatSessionManager } from "./chat-session-manager.js";
5
+ interface BackgroundResultWakeOptions {
6
+ sessionId: string;
7
+ manager: ChatSessionManager | null;
8
+ rehydrate(sessionId: string): Promise<ChatSession | null>;
9
+ approvalRouter: ApprovalRouter;
10
+ onStream(event: StreamEvent): void;
11
+ }
12
+ /**
13
+ * Drain pending background results into exactly one synthetic continuation.
14
+ * Busy sessions are awaited so a completion cannot fall into the gap between
15
+ * the notification bus callback and the interactive run-boundary re-check.
16
+ */
17
+ export declare function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, }: BackgroundResultWakeOptions): Promise<boolean>;
18
+ export {};
@@ -0,0 +1,101 @@
1
+ import { logger } from "../logging/logger.js";
2
+ import { buildNotificationMessage, notificationQueue, } from "../tool-system/builtin/agent-notifications.js";
3
+ /**
4
+ * Drain pending background results into exactly one synthetic continuation.
5
+ * Busy sessions are awaited so a completion cannot fall into the gap between
6
+ * the notification bus callback and the interactive run-boundary re-check.
7
+ */
8
+ export async function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, }) {
9
+ if (!manager) {
10
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "no_chat_manager" });
11
+ return false;
12
+ }
13
+ if (manager.isUnavailable(sessionId)) {
14
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_unavailable" });
15
+ return false;
16
+ }
17
+ let session = manager.get(sessionId) ?? (await rehydrate(sessionId));
18
+ if (!session) {
19
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_missing" });
20
+ return false;
21
+ }
22
+ while (session.isBusy()) {
23
+ logger.debug("bg_wakeup.waiting_for_idle", {
24
+ sessionId,
25
+ pendingCount: notificationQueue.getSnapshot(sessionId).length,
26
+ });
27
+ await session.settled;
28
+ if (manager.isUnavailable(sessionId)) {
29
+ logger.debug("bg_wakeup.skipped", {
30
+ sessionId,
31
+ reason: "session_became_unavailable",
32
+ });
33
+ return false;
34
+ }
35
+ const current = manager.get(sessionId);
36
+ if (!current) {
37
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "session_evicted_after_settle" });
38
+ return false;
39
+ }
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;
65
+ }
66
+ const pending = notificationQueue.drainAll(sessionId);
67
+ if (pending.length === 0) {
68
+ logger.debug("bg_wakeup.skipped", { sessionId, reason: "no_pending_results" });
69
+ return false;
70
+ }
71
+ const task = `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`;
72
+ try {
73
+ const result = await session.enqueueTurn(task, {
74
+ injected: true,
75
+ ...runWorkspace,
76
+ onStream,
77
+ approvalRouter,
78
+ });
79
+ if (result.turnCount === 0) {
80
+ const restored = notificationQueue.restoreResults(sessionId, pending);
81
+ logger.warn("bg_wakeup.turn_not_started", {
82
+ sessionId,
83
+ restored,
84
+ reason: result.reason,
85
+ text: result.text.slice(0, 500),
86
+ });
87
+ return false;
88
+ }
89
+ }
90
+ catch (error) {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ const restored = notificationQueue.restoreResults(sessionId, pending);
93
+ logger.warn("bg_wakeup.turn_failed", { sessionId, error: message, restored });
94
+ // A setup failure can occur before the turn loop emits its own terminal
95
+ // event. Emit an error so every renderer clears its busy state, but keep
96
+ // the result queued so a later user turn can consume it safely.
97
+ onStream({ type: "error", error: message || "background wakeup failed" });
98
+ return false;
99
+ }
100
+ return true;
101
+ }
@@ -218,10 +218,10 @@ export declare class AgentServer {
218
218
  * Guards:
219
219
  * - chatManager path only (the legacy single-engine / headless path drives
220
220
  * its own loop and has no idle-session-resume concept).
221
- * - Session must exist AND be idle. If it's busy, we do nothing: the in-flight
222
- * run's end-of-turn `drainAll` already collects every pending notification,
223
- * so a second run would be redundant and `enqueueTurn` while busy would
224
- * 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.
225
225
  * - We `drainAll` exactly here and feed the items into the woken turn. This
226
226
  * also merges a burst of near-simultaneous completions into one wakeup:
227
227
  * the first drains all currently-pending items; subsequent bus events for
@@ -18,12 +18,13 @@ 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";
@@ -498,22 +499,29 @@ export class AgentServer {
498
499
  // hands us.
499
500
  this.bgAgentBusUnsubscribe = agentNotificationBus.subscribe((envelope) => {
500
501
  const sessionId = envelope.to.sessionId;
501
- const event = notificationEnvelopeToLegacyStreamEvent(envelope);
502
- if (event)
503
- this.notify(Methods.StreamEvent, { sessionId, event });
504
- // 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
505
516
  // run_in_background Bash like a download, a background sub-agent, or a
506
517
  // video poll — the engine no longer parks on any of them) would otherwise
507
518
  // leave its completion sitting in the queue until the user manually sends.
508
519
  // Wake the session with one run carrying the notification so the model
509
520
  // reads "download complete" and continues on its own (the persisted goal
510
521
  // is judged that turn). If the work finishes while a run is still in
511
- // flight, the idle guard below skips it and the run-boundary re-check
512
- // (trigger B) drains it at end-of-turn instead. A never-exiting dev server
513
- // emits no completion, so it never wakes anything (no task/service
514
- // classification needed).
515
- if (envelope.kind === "result")
516
- 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).
517
525
  });
518
526
  // Notify client we're ready
519
527
  this.notify(Methods.Status, { status: "ready" });
@@ -527,10 +535,10 @@ export class AgentServer {
527
535
  * Guards:
528
536
  * - chatManager path only (the legacy single-engine / headless path drives
529
537
  * its own loop and has no idle-session-resume concept).
530
- * - Session must exist AND be idle. If it's busy, we do nothing: the in-flight
531
- * run's end-of-turn `drainAll` already collects every pending notification,
532
- * so a second run would be redundant and `enqueueTurn` while busy would
533
- * 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.
534
542
  * - We `drainAll` exactly here and feed the items into the woken turn. This
535
543
  * also merges a burst of near-simultaneous completions into one wakeup:
536
544
  * the first drains all currently-pending items; subsequent bus events for
@@ -551,67 +559,22 @@ export class AgentServer {
551
559
  this.maybeWakeIdleSession(sessionId);
552
560
  }
553
561
  })
554
- .catch(() => {
562
+ .catch((error) => {
555
563
  this.wakeupsInFlight.delete(sessionId);
564
+ logger.warn("bg_wakeup.failed", {
565
+ sessionId,
566
+ error: error instanceof Error ? error.message : String(error),
567
+ });
556
568
  });
557
569
  }
558
570
  async wakeIdleSession(sessionId) {
559
- if (!this.chatManager)
560
- return false;
561
- if (this.chatManager.isUnavailable(sessionId))
562
- return false;
563
- const session = this.chatManager.get(sessionId) ?? (await this.rehydrateSessionForWake(sessionId));
564
- if (!session || session.isBusy())
565
- return false;
566
- // Headless / automation runs are one-shot: the caller takes result.text and
567
- // is gone, so there's no consumer for a woken continuation turn. Headless
568
- // already drained its background sub-agents inside engine.run before
569
- // returning; any remaining queued notification (video/shell) must NOT spin
570
- // an orphan turn. Only the interactive path auto-continues.
571
- if (session.engine.isHeadless())
572
- return false;
573
- // Don't resurrect a session the user just Stopped: cancel() leaves it idle
574
- // (active=null) so isBusy() reads false, but auto-running a fresh turn here
575
- // would defeat the Stop. The flag clears the moment the user sends again.
576
- if (session.wasCancelledSinceLastTurn())
577
- return false;
578
- const pending = notificationQueue.drainAll(sessionId);
579
- if (pending.length === 0)
580
- return false;
581
- const task = `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`;
582
- try {
583
- await session.enqueueTurn(task, {
584
- // Synthetic notification, not the user's own input: persisted with an
585
- // `injected` flag so a disk rebuild doesn't render it as a phantom user
586
- // bubble (the live UI shows only the woken assistant's reply).
587
- injected: true,
588
- onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
589
- approvalRouter: this.approvalRouter,
590
- });
591
- }
592
- catch (err) {
593
- // A wakeup turn failing must not crash the bus fan-out. The drained
594
- // notifications are already in the transcript via the run's messages;
595
- // log and move on.
596
- logger.warn("bg_wakeup.turn_failed", {
597
- sessionId,
598
- error: err.message,
599
- });
600
- // Belt-and-braces, mirroring the send() path's run().then(clear-busy):
601
- // the renderer set the composer "working" spinner on this run's
602
- // session_started, and clears it on turn_complete/error. A failure
603
- // BEFORE the turn-loop runs (e.g. a setup error) emits neither, which
604
- // would leave the spinner stuck. Emit a terminal `error` (NOT a
605
- // turn_complete) so the renderer clears busy AND a woken automation
606
- // session's runStatus flips to "failed" rather than being mislabeled
607
- // "completed". If the turn-loop already emitted its own `error`, this is
608
- // a harmless duplicate (busy already cleared, status already failed).
609
- this.notify(Methods.StreamEvent, {
610
- sessionId,
611
- event: { type: "error", error: err?.message ?? "background wakeup failed" },
612
- });
613
- }
614
- 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
+ });
615
578
  }
616
579
  async rehydrateSessionForWake(sessionId) {
617
580
  if (!this.chatManager)
@@ -1286,11 +1249,12 @@ export class AgentServer {
1286
1249
  petWorkDelegation: result.petWorkDelegation,
1287
1250
  };
1288
1251
  this.transport.send(createResponse(req.id, runResult));
1289
- // Run-boundary re-check (trigger B): the session is idle now. If a
1290
- // background task (shell/video) completed DURING this run, its bus event
1291
- // fired while we were busy and trigger A skipped it drain it now and
1292
- // wake a continuation turn. Interactive path only; headless already
1293
- // 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.
1294
1258
  this.maybeWakeIdleSession(sid);
1295
1259
  }
1296
1260
  catch (err) {
@@ -192,6 +192,13 @@ export declare class SettingsManager {
192
192
  /** Resolve the project root once and refuse a linked/non-directory state root. */
193
193
  private tryProjectSettingsPath;
194
194
  private readJsonObject;
195
+ /**
196
+ * Same resolution as readJsonObject (JSON wins, sibling YAML is folded in),
197
+ * but a resolved file that cannot be read as a bounded object THROWS instead
198
+ * of degrading to {}. Only the read-modify-write path uses this: rewriting
199
+ * the whole object off a silently-empty read destroys the file's contents.
200
+ */
201
+ private readJsonObjectForMutation;
195
202
  private atomicWriteJson;
196
203
  private writeBackup;
197
204
  /**
@@ -367,7 +367,11 @@ export class SettingsManager {
367
367
  // from the added serialization.
368
368
  const release = acquireFileLock(path);
369
369
  try {
370
- const current = parseConfigFile(path) ?? {};
370
+ // Strict read: an existing-but-unreadable user settings file must abort
371
+ // the write rather than be rewritten from {}. This file can hold plaintext
372
+ // API keys, so silently replacing it with just the new key is the worst
373
+ // possible outcome. See readConfigFileForMutation.
374
+ const current = readConfigFileForMutation(path) ?? {};
371
375
  setDottedSetting(current, key, value);
372
376
  // Atomic write: stage to .tmp, then rename, so a concurrent read can't
373
377
  // catch a half-written file. mode 0o600 — settings.json can hold plaintext
@@ -575,6 +579,29 @@ export class SettingsManager {
575
579
  return {};
576
580
  return parseConfigFile(resolved) ?? {};
577
581
  }
582
+ /**
583
+ * Same resolution as readJsonObject (JSON wins, sibling YAML is folded in),
584
+ * but a resolved file that cannot be read as a bounded object THROWS instead
585
+ * of degrading to {}. Only the read-modify-write path uses this: rewriting
586
+ * the whole object off a silently-empty read destroys the file's contents.
587
+ */
588
+ readJsonObjectForMutation(path) {
589
+ const resolved = resolveConfigPath(path);
590
+ if (!resolved) {
591
+ // resolveConfigPath returns null both for "no candidate exists" and for
592
+ // "a candidate exists but is unsafe" (symlink, non-file, or over the size
593
+ // bound). Only the former may start from {}; the latter must not be
594
+ // overwritten, so re-check the candidates before deciding.
595
+ for (const candidate of settingsCandidatePaths(path)) {
596
+ if (existsSync(candidate)) {
597
+ throw new Error(`settings file exists but could not be read as a valid, bounded object: ${candidate}. ` +
598
+ `Refusing to overwrite it. Fix or move the file, then retry.`);
599
+ }
600
+ }
601
+ return {};
602
+ }
603
+ return readConfigFileForMutation(resolved) ?? {};
604
+ }
578
605
  atomicWriteJson(path, data) {
579
606
  assertSafeSettingsWriteTarget(path);
580
607
  const serialized = JSON.stringify(data, null, 2);
@@ -616,8 +643,10 @@ export class SettingsManager {
616
643
  try {
617
644
  assertSafeSettingsWriteTarget(path);
618
645
  // Re-read INSIDE the lock: a snapshot taken before acquiring it would be
619
- // exactly the stale value that drops the other writer's key.
620
- const current = this.readJsonObject(path);
646
+ // exactly the stale value that drops the other writer's key. Strict read:
647
+ // an existing-but-unreadable file must abort the mutation rather than be
648
+ // rewritten from {} — see readConfigFileForMutation.
649
+ const current = this.readJsonObjectForMutation(path);
621
650
  if (mutate(current) === false)
622
651
  return;
623
652
  this.atomicWriteJson(path, current);
@@ -698,6 +727,38 @@ function parseConfigFile(path) {
698
727
  }
699
728
  return null;
700
729
  }
730
+ /**
731
+ * Strict counterpart of `parseConfigFile` for the read-modify-write path.
732
+ *
733
+ * A mutation rewrites the WHOLE object, so it must be able to tell "absent"
734
+ * (start from {}) from "present but unreadable" (malformed, oversize, or
735
+ * otherwise unreadable). `parseConfigFile` deliberately folds every one of
736
+ * those to null so an ordinary load can skip a corrupt layer and still boot —
737
+ * but a writer that treats null as {} silently replaces the user's file with
738
+ * an object holding only the new key. Fail closed here instead; the caller's
739
+ * lock is still held, so the file is left byte-for-byte untouched.
740
+ *
741
+ * Returns undefined only when the file genuinely does not exist.
742
+ */
743
+ function readConfigFileForMutation(path) {
744
+ if (!existsSync(path))
745
+ return undefined;
746
+ const parsed = parseConfigFile(path);
747
+ if (parsed === null) {
748
+ // Deliberately does not include the file's contents — settings may hold
749
+ // plaintext credentials and this message reaches tool output.
750
+ throw new Error(`settings file exists but could not be read as a valid, bounded object: ${path}. ` +
751
+ `Refusing to overwrite it. Fix or move the file, then retry.`);
752
+ }
753
+ return parsed;
754
+ }
755
+ /** The .json path plus the sibling YAML paths resolveConfigPath would consider,
756
+ * in the same precedence order. Used by the mutation path to tell "nothing is
757
+ * there" from "something is there but unsafe to read". */
758
+ function settingsCandidatePaths(jsonPath) {
759
+ const base = jsonPath.replace(/\.json$/, "");
760
+ return [jsonPath, `${base}.yaml`, `${base}.yml`];
761
+ }
701
762
  /** Read through a no-follow descriptor so a settings-file symlink cannot escape its layer. */
702
763
  function readBoundedRegularFile(path) {
703
764
  let fd;
@@ -132,6 +132,14 @@ declare class NotificationQueue {
132
132
  drain(sessionId: string, predicate: (envelope: NotificationEnvelope) => boolean): NotificationEnvelope[];
133
133
  /** Compatibility consumer: only terminal results, never direction/progress. */
134
134
  drainAll(sessionId: string): ResultEnvelope[];
135
+ /**
136
+ * Restore terminal results that a consumer drained but could not deliver.
137
+ * Existing envelopes retain their ids/sequences and are prepended ahead of
138
+ * results that arrived during the failed delivery attempt. This is an
139
+ * internal mailbox rollback, so it deliberately does not republish bus
140
+ * events (which would recursively schedule another wake immediately).
141
+ */
142
+ restoreResults(sessionId: string, envelopes: readonly ResultEnvelope[]): number;
135
143
  clearProgress(sessionId: string, agentId: string, runtimeGeneration?: number): boolean;
136
144
  clearDirections(sessionId: string, runtimeGeneration: number): boolean;
137
145
  reset(sessionId?: string): void;
@@ -191,6 +191,25 @@ class NotificationQueue {
191
191
  drainAll(sessionId) {
192
192
  return this.drain(sessionId, (item) => item.kind === "result");
193
193
  }
194
+ /**
195
+ * Restore terminal results that a consumer drained but could not deliver.
196
+ * Existing envelopes retain their ids/sequences and are prepended ahead of
197
+ * results that arrived during the failed delivery attempt. This is an
198
+ * internal mailbox rollback, so it deliberately does not republish bus
199
+ * events (which would recursively schedule another wake immediately).
200
+ */
201
+ restoreResults(sessionId, envelopes) {
202
+ if (!isValidSessionId(sessionId) || envelopes.length === 0)
203
+ return 0;
204
+ const bucket = this.buckets.get(sessionId) ?? [];
205
+ const ids = new Set(bucket.map((item) => item.id));
206
+ const restored = envelopes.filter((item) => item.kind === "result" && item.to.sessionId === sessionId && !ids.has(item.id));
207
+ if (restored.length === 0)
208
+ return 0;
209
+ this.buckets.set(sessionId, [...restored, ...bucket]);
210
+ this.notify();
211
+ return restored.length;
212
+ }
194
213
  clearProgress(sessionId, agentId, runtimeGeneration) {
195
214
  const bucket = this.buckets.get(sessionId);
196
215
  if (!bucket?.length)
@@ -2,6 +2,17 @@
2
2
  * ConfigTool — read or update project settings.
3
3
  */
4
4
  import type { ToolDefinition } from "../../types.js";
5
+ import { SettingsManager } from "../../settings/manager.js";
5
6
  import type { ToolContext } from "../context.js";
6
7
  export declare const configToolDef: ToolDefinition;
8
+ export interface ConfigToolDeps {
9
+ makeSettingsManager(cwd: string, scope: "full" | "project"): SettingsManager;
10
+ /** Test seam: awaited inside the write path, after the key/value checks and
11
+ * before the value is persisted, so a test can park one writer in the
12
+ * read→write window and drive a deterministic interleaving. */
13
+ beforeWrite?: () => Promise<void>;
14
+ }
15
+ /** Factory so tests can inject a SettingsManager (barrier/fake); production
16
+ * uses the default instance-per-call, matching the other builtins. */
17
+ export declare function makeConfigTool(deps?: ConfigToolDeps): (args: Record<string, unknown>, ctx?: ToolContext) => Promise<string>;
7
18
  export declare function configTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * ConfigTool — read or update project settings.
3
3
  */
4
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { existsSync, readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
- import { setDottedSetting } from "../../settings/manager.js";
6
+ import { SettingsManager, setDottedSetting } from "../../settings/manager.js";
7
7
  import { enforcePathPolicyWithApproval } from "../path-policy.js";
8
8
  export const configToolDef = {
9
9
  name: "Config",
@@ -28,7 +28,20 @@ export const configToolDef = {
28
28
  required: ["action"],
29
29
  },
30
30
  };
31
+ const DEFAULT_DEPS = {
32
+ makeSettingsManager: (cwd, scope) => new SettingsManager(cwd, scope),
33
+ };
34
+ /** Factory so tests can inject a SettingsManager (barrier/fake); production
35
+ * uses the default instance-per-call, matching the other builtins. */
36
+ export function makeConfigTool(deps = DEFAULT_DEPS) {
37
+ return async function configTool(args, ctx) {
38
+ return runConfigTool(args, ctx, deps);
39
+ };
40
+ }
31
41
  export async function configTool(args, ctx) {
42
+ return runConfigTool(args, ctx, DEFAULT_DEPS);
43
+ }
44
+ async function runConfigTool(args, ctx, deps) {
32
45
  const action = args.action;
33
46
  const cwd = ctx?.cwd ?? process.cwd();
34
47
  const configPath = join(cwd, ".code-shell", "settings.json");
@@ -52,18 +65,18 @@ export async function configTool(args, ctx) {
52
65
  return "Error: 'key' is required for write action.";
53
66
  if (value === undefined)
54
67
  return "Error: 'value' is required for write action.";
55
- // Read or create settings
56
- let settings = {};
57
- if (existsSync(configPath)) {
58
- settings = JSON.parse(readFileSync(configPath, "utf-8"));
59
- }
68
+ // Validate the key BEFORE touching disk, preserving the existing contract
69
+ // that an unsafe dotted key is rejected without creating settings.json.
70
+ // setDottedSetting is the same validator saveProjectSetting applies inside
71
+ // the lock; running it here on a throwaway object only surfaces the error.
60
72
  try {
61
- setDottedSetting(settings, key, value);
73
+ setDottedSetting({}, key, value);
62
74
  }
63
75
  catch (error) {
64
76
  const message = error instanceof Error ? error.message : String(error);
65
77
  return `Error: ${message}`;
66
78
  }
79
+ await deps.beforeWrite?.();
67
80
  // Never resurrect a deleted project root: a recursive mkdir of
68
81
  // <cwd>/.code-shell would recreate `cwd` itself as an empty shell when the
69
82
  // directory has been deleted (e.g. a stale session pointing at a removed
@@ -71,8 +84,40 @@ export async function configTool(args, ctx) {
71
84
  if (!existsSync(cwd)) {
72
85
  return `Error: project directory does not exist: ${cwd}`;
73
86
  }
74
- mkdirSync(join(cwd, ".code-shell"), { recursive: true });
75
- writeFileSync(configPath, JSON.stringify(settings, null, 2), "utf-8");
87
+ // Persist through SettingsManager rather than a hand-rolled
88
+ // read → modify → writeFileSync. That path had no lock and no temp+rename,
89
+ // so two writers that both read before either wrote each persisted their
90
+ // own stale snapshot and silently dropped the other's key (the class
91
+ // documented in utils/file-mutex.ts). saveProjectSetting re-reads inside
92
+ // the lock, writes atomically, and invalidates the merged cache so a
93
+ // following read sees this write.
94
+ //
95
+ // Side effect worth knowing about on a YAML-configured project:
96
+ // SettingsManager reads a sibling settings.yaml when settings.json is
97
+ // absent but always writes back JSON, so the first write folds the YAML
98
+ // content into a new settings.json and the now-shadowed YAML is left on
99
+ // disk. That is SettingsManager's established behaviour for every caller,
100
+ // and it is strictly better than what this tool used to do (write the one
101
+ // new key and lose the YAML content entirely) — so it is inherited
102
+ // deliberately rather than special-cased here.
103
+ //
104
+ // saveProjectSetting throws on a hostile state dir (a `.code-shell` that is
105
+ // a file or a link) and on an existing-but-unreadable settings file, where
106
+ // it refuses to overwrite rather than rewrite from {}. The old hand-rolled
107
+ // write also threw on those inputs, but let the exception escape; every
108
+ // other failure in this tool is reported as a string, so convert it here.
109
+ //
110
+ // Note the file mode also changed: the old writeFileSync left settings.json
111
+ // at the umask default (typically 0644), while SettingsManager writes 0600.
112
+ // settings.json can hold plaintext API keys, so owner-only is the intended
113
+ // posture — an existing world-readable file is tightened on its next write.
114
+ try {
115
+ deps.makeSettingsManager(cwd, "project").saveProjectSetting(key, value, cwd);
116
+ }
117
+ catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ return `Error: ${message}`;
120
+ }
76
121
  return `Updated ${key} = ${JSON.stringify(value)}`;
77
122
  }
78
123
  return `Unknown action: ${action}. Use 'read' or 'write'.`;
@@ -39,7 +39,8 @@ export const viewImageToolDef = {
39
39
  },
40
40
  imageNumber: {
41
41
  type: "number",
42
- description: "Image history number N from an earlier [image #N, already provided] placeholder.",
42
+ description: "Positive image history number N from an earlier [image #N, already provided] " +
43
+ "placeholder. Omit this field entirely when path is provided; do not send 0.",
43
44
  },
44
45
  detail: {
45
46
  type: "string",
@@ -53,7 +54,12 @@ export async function viewImageTool(args, ctx) {
53
54
  const rawPath = args.path;
54
55
  const rawImageNumber = args.imageNumber;
55
56
  const hasPath = typeof rawPath === "string" && rawPath.trim().length > 0;
56
- const hasImageNumber = rawImageNumber !== undefined && rawImageNumber !== null;
57
+ const hasImageNumberInput = rawImageNumber !== undefined && rawImageNumber !== null;
58
+ // Some tool-calling models materialize every optional numeric property with
59
+ // a zero sentinel. With a real path, imageNumber: 0 unambiguously means
60
+ // "unused" because valid history numbers start at 1. Accept that one
61
+ // compatibility shape while keeping path + a real image number forbidden.
62
+ const hasImageNumber = hasImageNumberInput && !(hasPath && rawImageNumber === 0);
57
63
  if (hasPath === hasImageNumber) {
58
64
  return "Error: provide exactly one of path or imageNumber";
59
65
  }
@@ -22,5 +22,14 @@ export declare function validateWorkspaceContext(value: unknown): WorkspaceConte
22
22
  /** Compatibility context for callers that only possess one cwd. It is never persisted as a binding. */
23
23
  export declare function legacySingleRootWorkspace(cwd: string): WorkspaceContext;
24
24
  export declare function workspacePrimaryRoot(context: WorkspaceContext): ProjectRootContext;
25
+ /**
26
+ * Rebase only the Session's primary runtime path while preserving the host's
27
+ * authoritative project/root identities and the mounted secondary roots.
28
+ *
29
+ * A worktree switch changes where the primary root executes; it does not mint
30
+ * a new project or root id. Rebuilding through createWorkspaceContext also
31
+ * recomputes rootsDigest and re-runs the overlap/absolute-path validation.
32
+ */
33
+ export declare function rebaseWorkspacePrimaryRoot(context: WorkspaceContext, primaryPath: string): WorkspaceContext;
25
34
  /** Paths present in the previous run-scoped root set but absent from the next one. */
26
35
  export declare function removedWorkspaceRootPaths(previous: Pick<WorkspaceContext, "roots">, next: Pick<WorkspaceContext, "roots">): string[];
@@ -103,6 +103,22 @@ export function legacySingleRootWorkspace(cwd) {
103
103
  export function workspacePrimaryRoot(context) {
104
104
  return context.roots.find((root) => root.id === context.sessionMainRootId);
105
105
  }
106
+ /**
107
+ * Rebase only the Session's primary runtime path while preserving the host's
108
+ * authoritative project/root identities and the mounted secondary roots.
109
+ *
110
+ * A worktree switch changes where the primary root executes; it does not mint
111
+ * a new project or root id. Rebuilding through createWorkspaceContext also
112
+ * recomputes rootsDigest and re-runs the overlap/absolute-path validation.
113
+ */
114
+ export function rebaseWorkspacePrimaryRoot(context, primaryPath) {
115
+ return createWorkspaceContext({
116
+ projectId: context.projectId,
117
+ projectRevision: context.projectRevision,
118
+ sessionMainRootId: context.sessionMainRootId,
119
+ roots: context.roots.map((root) => root.id === context.sessionMainRootId ? { ...root, path: primaryPath } : root),
120
+ });
121
+ }
106
122
  /** Paths present in the previous run-scoped root set but absent from the next one. */
107
123
  export function removedWorkspaceRootPaths(previous, next) {
108
124
  const nextKeys = new Set(next.roots.map((root) => canonicalKey(root.path)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",