@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.
- package/dist/engine/engine-workspace-authority.d.ts +15 -0
- package/dist/engine/engine-workspace-authority.js +32 -1
- package/dist/engine/engine.d.ts +2 -0
- package/dist/engine/engine.js +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol/background-result-wakeup.d.ts +18 -0
- package/dist/protocol/background-result-wakeup.js +101 -0
- package/dist/protocol/server.d.ts +4 -4
- package/dist/protocol/server.js +41 -77
- package/dist/settings/manager.d.ts +7 -0
- package/dist/settings/manager.js +64 -3
- package/dist/tool-system/builtin/agent-notifications.d.ts +8 -0
- package/dist/tool-system/builtin/agent-notifications.js +19 -0
- package/dist/tool-system/builtin/config.d.ts +11 -0
- package/dist/tool-system/builtin/config.js +55 -10
- package/dist/tool-system/builtin/view-image.js +8 -2
- package/dist/workspace/workspace-context.d.ts +9 -0
- package/dist/workspace/workspace-context.js +16 -0
- package/package.json +1 -1
|
@@ -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);
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -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.
|
package/dist/engine/engine.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
package/dist/protocol/server.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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,
|
|
512
|
-
//
|
|
513
|
-
//
|
|
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
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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):
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// drained its sub-agents inside
|
|
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
|
/**
|
package/dist/settings/manager.js
CHANGED
|
@@ -367,7 +367,11 @@ export class SettingsManager {
|
|
|
367
367
|
// from the added serialization.
|
|
368
368
|
const release = acquireFileLock(path);
|
|
369
369
|
try {
|
|
370
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
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
|
-
|
|
75
|
-
writeFileSync
|
|
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: "
|
|
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
|
|
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