@cjhyy/code-shell-core 0.8.20 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/automation/desktop-authority-client.d.ts +9 -0
- package/dist/automation/desktop-authority-client.js +47 -0
- package/dist/automation/scheduler.d.ts +8 -0
- package/dist/automation/scheduler.js +37 -1
- package/dist/automation/store.js +14 -1
- package/dist/capabilities/index.d.ts +1 -0
- package/dist/cli/agent-server-stdio.js +4 -1
- package/dist/engine/engine-workspace-authority.d.ts +50 -0
- package/dist/engine/engine-workspace-authority.js +167 -0
- package/dist/engine/engine.d.ts +7 -10
- package/dist/engine/engine.js +87 -101
- package/dist/engine/input-attachments.d.ts +1 -0
- package/dist/engine/input-attachments.js +6 -2
- package/dist/engine/run-environment.d.ts +8 -3
- package/dist/engine/run-environment.js +33 -8
- package/dist/engine/run-image-input.d.ts +1 -0
- package/dist/engine/run-image-input.js +1 -0
- package/dist/engine/run-session-open.d.ts +2 -1
- package/dist/engine/run-session-open.js +6 -0
- package/dist/engine/run-setup.d.ts +1 -0
- package/dist/engine/run-setup.js +2 -1
- package/dist/engine/run-tooling.d.ts +2 -0
- package/dist/engine/run-tooling.js +3 -0
- package/dist/engine/run-types.d.ts +2 -0
- package/dist/engine/run-workspace.d.ts +6 -1
- package/dist/engine/run-workspace.js +47 -0
- package/dist/engine/subagent-spawner.d.ts +1 -0
- package/dist/engine/subagent-spawner.js +1 -0
- package/dist/engine/types.d.ts +2 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.extension.d.ts +1 -1
- package/dist/index.internal.d.ts +2 -2
- package/dist/index.internal.js +1 -0
- package/dist/index.js +1 -1
- package/dist/plugins/pluginAutomationTemplates.d.ts +2 -0
- package/dist/plugins/pluginAutomationTemplates.js +4 -0
- package/dist/prompt/composer.d.ts +4 -1
- package/dist/prompt/composer.js +11 -3
- package/dist/protocol/background-result-wakeup.d.ts +18 -0
- package/dist/protocol/background-result-wakeup.js +101 -0
- package/dist/protocol/chat-session-manager.d.ts +42 -1
- package/dist/protocol/chat-session-manager.js +167 -4
- package/dist/protocol/chat-session.d.ts +9 -1
- package/dist/protocol/chat-session.js +20 -2
- package/dist/protocol/mobile-remote-types.d.ts +15 -0
- package/dist/protocol/server.d.ts +5 -6
- package/dist/protocol/server.js +62 -128
- package/dist/protocol/session-workspace-rpc.d.ts +23 -0
- package/dist/protocol/session-workspace-rpc.js +171 -0
- package/dist/protocol/types.d.ts +45 -1
- package/dist/protocol/types.js +4 -0
- package/dist/session/session-manager.d.ts +18 -0
- package/dist/session/session-manager.js +87 -0
- 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/cron.d.ts +11 -0
- package/dist/tool-system/builtin/cron.js +27 -2
- package/dist/tool-system/builtin/edit.js +5 -3
- package/dist/tool-system/builtin/view-image.js +8 -2
- package/dist/tool-system/builtin/write.js +5 -3
- package/dist/tool-system/context.d.ts +4 -1
- package/dist/tool-system/executor.js +1 -1
- package/dist/tool-system/path-policy.d.ts +11 -3
- package/dist/tool-system/path-policy.js +56 -36
- package/dist/types.d.ts +6 -0
- package/dist/workspace/canonical-key.d.ts +7 -0
- package/dist/workspace/canonical-key.js +39 -0
- package/dist/workspace/workspace-context.d.ts +35 -0
- package/dist/workspace/workspace-context.js +128 -0
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Engine } from "../engine/engine.js";
|
|
2
|
+
import type { ChatSession } from "./chat-session.js";
|
|
3
|
+
import type { ChatSessionManager, EngineConfigSlice } from "./chat-session-manager.js";
|
|
4
|
+
import type { Transport } from "./transport.js";
|
|
5
|
+
import { type RpcRequest } from "./types.js";
|
|
6
|
+
interface SessionWorkspaceRpcDependencies {
|
|
7
|
+
transport: Transport;
|
|
8
|
+
getChatManager(): ChatSessionManager | null;
|
|
9
|
+
getLegacyEngine(): Engine | null;
|
|
10
|
+
getLastSlice(sessionId: string): EngineConfigSlice | undefined;
|
|
11
|
+
rememberSessionSlice(sessionId: string, slice: EngineConfigSlice): void;
|
|
12
|
+
wireInteractiveSession(session: ChatSession, sessionId: string): void;
|
|
13
|
+
}
|
|
14
|
+
/** Protocol boundary for Session workspace ownership and root migration. */
|
|
15
|
+
export declare class SessionWorkspaceRpcHandlers {
|
|
16
|
+
private readonly deps;
|
|
17
|
+
constructor(deps: SessionWorkspaceRpcDependencies);
|
|
18
|
+
release(req: RpcRequest): void;
|
|
19
|
+
set(req: RpcRequest): void;
|
|
20
|
+
migrateMainRoot(req: RpcRequest): Promise<void>;
|
|
21
|
+
completeMainRootMigration(req: RpcRequest): void;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { canonicalKey } from "../workspace/canonical-key.js";
|
|
2
|
+
import { validateWorkspaceContext, workspacePrimaryRoot, } from "../workspace/workspace-context.js";
|
|
3
|
+
import { createErrorResponse, createResponse, ErrorCodes, } from "./types.js";
|
|
4
|
+
/** Protocol boundary for Session workspace ownership and root migration. */
|
|
5
|
+
export class SessionWorkspaceRpcHandlers {
|
|
6
|
+
deps;
|
|
7
|
+
constructor(deps) {
|
|
8
|
+
this.deps = deps;
|
|
9
|
+
}
|
|
10
|
+
release(req) {
|
|
11
|
+
const params = (req.params ?? {});
|
|
12
|
+
if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
|
|
13
|
+
this.deps.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const manager = this.deps.getChatManager();
|
|
17
|
+
if (manager) {
|
|
18
|
+
const session = manager.get(params.sessionId);
|
|
19
|
+
if (!session) {
|
|
20
|
+
this.deps.transport.send(createResponse(req.id, { ok: true, workspace: null }));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const workspace = session.engine.releaseSessionWorkspace?.(params.sessionId) ?? null;
|
|
24
|
+
this.deps.transport.send(createResponse(req.id, { ok: true, workspace }));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const workspace = this.deps.getLegacyEngine()?.releaseSessionWorkspace?.(params.sessionId) ?? null;
|
|
28
|
+
this.deps.transport.send(createResponse(req.id, { ok: true, workspace }));
|
|
29
|
+
}
|
|
30
|
+
set(req) {
|
|
31
|
+
const params = (req.params ?? {});
|
|
32
|
+
if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
|
|
33
|
+
this.deps.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!params.workspace ||
|
|
37
|
+
typeof params.workspace !== "object" ||
|
|
38
|
+
typeof params.workspace.root !== "string" ||
|
|
39
|
+
params.workspace.root.length === 0 ||
|
|
40
|
+
(params.workspace.kind !== "main" && params.workspace.kind !== "worktree")) {
|
|
41
|
+
this.deps.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "valid workspace is required"));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const manager = this.deps.getChatManager();
|
|
45
|
+
const engine = manager ? manager.get(params.sessionId)?.engine : this.deps.getLegacyEngine();
|
|
46
|
+
if (!engine) {
|
|
47
|
+
this.deps.transport.send(createResponse(req.id, { ok: true, workspace: null }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const workspace = engine.setSessionWorkspace?.(params.sessionId, params.workspace);
|
|
51
|
+
this.deps.transport.send(createResponse(req.id, {
|
|
52
|
+
ok: workspace !== undefined && workspace !== null,
|
|
53
|
+
workspace: workspace ?? null,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
async migrateMainRoot(req) {
|
|
57
|
+
const params = (req.params ?? {});
|
|
58
|
+
if (!validMigrationParams(params)) {
|
|
59
|
+
this.deps.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "valid Session root migration is required"));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let workspaceContext;
|
|
63
|
+
try {
|
|
64
|
+
workspaceContext = validateWorkspaceContext(params.workspaceContext);
|
|
65
|
+
const primary = workspacePrimaryRoot(workspaceContext);
|
|
66
|
+
if (workspaceContext.projectId !== params.project.projectId ||
|
|
67
|
+
workspaceContext.sessionMainRootId !== params.project.mainRootId ||
|
|
68
|
+
canonicalKey(primary.path) !== canonicalKey(params.mainRoot)) {
|
|
69
|
+
throw new Error("migration target authority does not match the requested root");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.deps.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, error instanceof Error ? error.message : "valid target authority is required"));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const manager = this.deps.getChatManager();
|
|
77
|
+
let engine;
|
|
78
|
+
let residentSession;
|
|
79
|
+
if (manager) {
|
|
80
|
+
const ownership = manager.beginSessionMigration(params.sessionId, params.ownershipToken);
|
|
81
|
+
if (ownership.status === "not-resident") {
|
|
82
|
+
this.deps.transport.send(createResponse(req.id, {
|
|
83
|
+
status: "not-resident",
|
|
84
|
+
ownershipToken: ownership.ownershipToken,
|
|
85
|
+
}));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (ownership.status === "failed") {
|
|
89
|
+
this.deps.transport.send(createResponse(req.id, {
|
|
90
|
+
status: "failed",
|
|
91
|
+
error: ownership.error,
|
|
92
|
+
}));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
residentSession = ownership.session;
|
|
96
|
+
engine = residentSession.engine;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
engine = this.deps.getLegacyEngine();
|
|
100
|
+
}
|
|
101
|
+
if (!engine) {
|
|
102
|
+
this.deps.transport.send(createResponse(req.id, {
|
|
103
|
+
status: "failed",
|
|
104
|
+
error: "Session migration owner is unavailable",
|
|
105
|
+
}));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const workspace = residentSession
|
|
110
|
+
? await manager.migrateResidentSessionMainRoot(params.sessionId, {
|
|
111
|
+
project: params.project,
|
|
112
|
+
mainRoot: params.mainRoot,
|
|
113
|
+
workspaceContext,
|
|
114
|
+
projectTrusted: params.projectTrusted,
|
|
115
|
+
})
|
|
116
|
+
: engine.migrateSessionMainRoot?.(params.sessionId, params.project, params.mainRoot);
|
|
117
|
+
if (!workspace)
|
|
118
|
+
throw new Error(`Session ${params.sessionId} migration was not committed`);
|
|
119
|
+
if (residentSession) {
|
|
120
|
+
this.deps.rememberSessionSlice(params.sessionId, {
|
|
121
|
+
...(this.deps.getLastSlice(params.sessionId) ?? {}),
|
|
122
|
+
cwd: params.mainRoot,
|
|
123
|
+
workspaceContext,
|
|
124
|
+
projectTrusted: params.projectTrusted,
|
|
125
|
+
});
|
|
126
|
+
this.deps.wireInteractiveSession(residentSession, params.sessionId);
|
|
127
|
+
}
|
|
128
|
+
this.deps.transport.send(createResponse(req.id, {
|
|
129
|
+
status: "migrated",
|
|
130
|
+
workspace,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
this.deps.transport.send(createResponse(req.id, {
|
|
135
|
+
status: "failed",
|
|
136
|
+
error: error instanceof Error ? error.message : String(error),
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
completeMainRootMigration(req) {
|
|
141
|
+
const params = (req.params ?? {});
|
|
142
|
+
if (typeof params.sessionId !== "string" ||
|
|
143
|
+
params.sessionId.length === 0 ||
|
|
144
|
+
typeof params.ownershipToken !== "string" ||
|
|
145
|
+
params.ownershipToken.length === 0 ||
|
|
146
|
+
params.ownershipToken.length > 128) {
|
|
147
|
+
this.deps.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "valid migration claim is required"));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!this.deps.getChatManager()?.completeSessionMigration(params.sessionId, params.ownershipToken)) {
|
|
151
|
+
this.deps.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "migration claim is not active"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.deps.transport.send(createResponse(req.id, { released: true }));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function validMigrationParams(params) {
|
|
158
|
+
return (typeof params.sessionId === "string" &&
|
|
159
|
+
params.sessionId.length > 0 &&
|
|
160
|
+
Boolean(params.project) &&
|
|
161
|
+
typeof params.project?.projectId === "string" &&
|
|
162
|
+
params.project.projectId.length > 0 &&
|
|
163
|
+
typeof params.project.mainRootId === "string" &&
|
|
164
|
+
params.project.mainRootId.length > 0 &&
|
|
165
|
+
typeof params.mainRoot === "string" &&
|
|
166
|
+
params.mainRoot.length > 0 &&
|
|
167
|
+
typeof params.projectTrusted === "boolean" &&
|
|
168
|
+
typeof params.ownershipToken === "string" &&
|
|
169
|
+
params.ownershipToken.length > 0 &&
|
|
170
|
+
params.ownershipToken.length <= 128);
|
|
171
|
+
}
|
package/dist/protocol/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Server → Client: notifications (stream events, approval requests)
|
|
8
8
|
* Server → Client: responses (results of requests)
|
|
9
9
|
*/
|
|
10
|
-
import type { InputAttachmentMeta, LegacyPetWorkspaceOption, LegacyPetWorkDelegation, StreamEvent, TokenUsage, TerminalReason, ApprovalRequest, ApprovalResult, PermissionMode, SessionForkLineage, SessionKind, SessionWorkspace } from "../types.js";
|
|
10
|
+
import type { InputAttachmentMeta, LegacyPetWorkspaceOption, LegacyPetWorkDelegation, StreamEvent, TokenUsage, TerminalReason, ApprovalRequest, ApprovalResult, PermissionMode, SessionForkLineage, SessionKind, SessionProjectBinding, SessionWorkspace } from "../types.js";
|
|
11
11
|
import type { RunBehaviorMode } from "../engine/run-types.js";
|
|
12
12
|
import type { SessionMessageTarget } from "../session/session-message.js";
|
|
13
13
|
export type PendingApprovalKind = "tool_approval" | "ask_user" | "internal";
|
|
@@ -69,6 +69,12 @@ export type { InputAttachmentKind, InputAttachmentOrigin, InputAttachmentMeta }
|
|
|
69
69
|
export interface RunParams {
|
|
70
70
|
sessionId: string;
|
|
71
71
|
task: string;
|
|
72
|
+
/** Renderer/mobile project hint. Desktop Main resolves it to authoritative roots. */
|
|
73
|
+
projectId?: string;
|
|
74
|
+
/** Renderer/mobile root hint. Desktop Main resolves it within projectId. */
|
|
75
|
+
rootId?: string;
|
|
76
|
+
/** Trusted host-injected roots. Desktop Main strips any renderer/mobile supplied value. */
|
|
77
|
+
workspaceContext?: import("../workspace/workspace-context.js").WorkspaceContext;
|
|
72
78
|
/**
|
|
73
79
|
* Optional user-facing representation of `task`. The model still receives
|
|
74
80
|
* the full task, while session feeds and transcript replay show this text.
|
|
@@ -273,6 +279,40 @@ export interface SetWorkspaceParams {
|
|
|
273
279
|
sessionId: string;
|
|
274
280
|
workspace: SessionWorkspace;
|
|
275
281
|
}
|
|
282
|
+
/** Atomically rebind every main-root field through the live Session owner. */
|
|
283
|
+
export interface MigrateSessionMainRootParams {
|
|
284
|
+
sessionId: string;
|
|
285
|
+
project: SessionProjectBinding;
|
|
286
|
+
mainRoot: string;
|
|
287
|
+
/** Main-resolved target authority used to construct the replacement Engine. */
|
|
288
|
+
workspaceContext: import("../workspace/workspace-context.js").WorkspaceContext;
|
|
289
|
+
/** Trust decision for the target main root, resolved by the host trust store. */
|
|
290
|
+
projectTrusted: boolean;
|
|
291
|
+
/** Main-minted nonce used to fence and later release a non-resident handoff. */
|
|
292
|
+
ownershipToken: string;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Proven ownership result for a Session main-root migration.
|
|
296
|
+
*
|
|
297
|
+
* `not-resident` is the only result that authorizes a durable host fallback.
|
|
298
|
+
* The ownership token fences a concurrent getOrCreate until the host reports
|
|
299
|
+
* that its atomic disk commit (or failed attempt) has finished.
|
|
300
|
+
*/
|
|
301
|
+
export type MigrateSessionMainRootResult = {
|
|
302
|
+
status: "migrated";
|
|
303
|
+
workspace: SessionWorkspace;
|
|
304
|
+
} | {
|
|
305
|
+
status: "not-resident";
|
|
306
|
+
ownershipToken: string;
|
|
307
|
+
} | {
|
|
308
|
+
status: "failed";
|
|
309
|
+
error: string;
|
|
310
|
+
};
|
|
311
|
+
/** Release the non-resident ownership fence after Main's durable attempt. */
|
|
312
|
+
export interface CompleteSessionMainRootMigrationParams {
|
|
313
|
+
sessionId: string;
|
|
314
|
+
ownershipToken: string;
|
|
315
|
+
}
|
|
276
316
|
/** Inject context into a session transcript. */
|
|
277
317
|
export interface InjectParams {
|
|
278
318
|
sessionId: string;
|
|
@@ -500,6 +540,10 @@ export declare const Methods: {
|
|
|
500
540
|
readonly ReleaseWorkspace: "agent/releaseWorkspace";
|
|
501
541
|
/** Persist a workspace pointer through the live session owner. */
|
|
502
542
|
readonly SetWorkspace: "agent/setWorkspace";
|
|
543
|
+
/** Atomically migrate a Session's main-root authority through its live owner. */
|
|
544
|
+
readonly MigrateSessionMainRoot: "agent/migrateSessionMainRoot";
|
|
545
|
+
/** Release a non-resident migration fence after Main's durable attempt. */
|
|
546
|
+
readonly CompleteSessionMainRootMigration: "agent/completeSessionMainRootMigration";
|
|
503
547
|
/** Extend a running goal's turn/budget ceilings mid-run (TODO 3.1). */
|
|
504
548
|
readonly GoalExtend: "agent/goalExtend";
|
|
505
549
|
/** Edit or pause/resume a session's persisted goal. */
|
package/dist/protocol/types.js
CHANGED
|
@@ -46,6 +46,10 @@ export const Methods = {
|
|
|
46
46
|
ReleaseWorkspace: "agent/releaseWorkspace",
|
|
47
47
|
/** Persist a workspace pointer through the live session owner. */
|
|
48
48
|
SetWorkspace: "agent/setWorkspace",
|
|
49
|
+
/** Atomically migrate a Session's main-root authority through its live owner. */
|
|
50
|
+
MigrateSessionMainRoot: "agent/migrateSessionMainRoot",
|
|
51
|
+
/** Release a non-resident migration fence after Main's durable attempt. */
|
|
52
|
+
CompleteSessionMainRootMigration: "agent/completeSessionMainRootMigration",
|
|
49
53
|
/** Extend a running goal's turn/budget ceilings mid-run (TODO 3.1). */
|
|
50
54
|
GoalExtend: "agent/goalExtend",
|
|
51
55
|
/** Edit or pause/resume a session's persisted goal. */
|
|
@@ -118,6 +118,8 @@ export declare class SessionManager {
|
|
|
118
118
|
* fallback when a worktree is unavailable or is being released.
|
|
119
119
|
*/
|
|
120
120
|
readSessionMainRoot(sessionId: string): string | undefined;
|
|
121
|
+
/** Cheap durable project-binding read; absent for legacy/no-repo Sessions. */
|
|
122
|
+
readSessionProjectBinding(sessionId: string): import("../types.js").SessionProjectBinding | undefined;
|
|
121
123
|
/** Cheap durable classification read. Legacy sessions are ordinary work sessions. */
|
|
122
124
|
readSessionKind(sessionId: string): SessionKind | undefined;
|
|
123
125
|
/** Cheap durable digital-human binding read. */
|
|
@@ -154,6 +156,22 @@ export declare class SessionManager {
|
|
|
154
156
|
* is a safety pointer and resume breadcrumb.
|
|
155
157
|
*/
|
|
156
158
|
setSessionWorkspace(sessionId: string, workspace: SessionWorkspace): number;
|
|
159
|
+
/**
|
|
160
|
+
* Commit a Session main-root migration as one state.json replacement.
|
|
161
|
+
*
|
|
162
|
+
* `cwd`, the durable project binding, and the execution workspace are one
|
|
163
|
+
* consistency unit: exposing any subset would let a resumed run combine the
|
|
164
|
+
* old authority with a new path. Derived availability such as dir_missing is
|
|
165
|
+
* deliberately absent from this patch and is recomputed by the host.
|
|
166
|
+
*/
|
|
167
|
+
migrateSessionMainRoot(sessionId: string, project: import("../types.js").SessionProjectBinding, mainRoot: string): number;
|
|
168
|
+
/**
|
|
169
|
+
* Commit a host-owned offline migration only if the durable snapshot Main
|
|
170
|
+
* just revalidated is still current. Unlike the ordinary field-level writer,
|
|
171
|
+
* this must not retry onto a newer revision: a new writer means ownership is
|
|
172
|
+
* no longer provably unchanged, so the migration fails closed.
|
|
173
|
+
*/
|
|
174
|
+
migrateSessionMainRootIfRevision(sessionId: string, project: import("../types.js").SessionProjectBinding, mainRoot: string, expectedStateRevision: number | undefined): number;
|
|
157
175
|
/** Read the durable archival timestamp; undefined = not archived / unprovable. */
|
|
158
176
|
readSessionArchivedAt(sessionId: string): number | undefined;
|
|
159
177
|
/** Set (number) or clear (undefined) the durable archival marker. */
|
|
@@ -505,6 +505,29 @@ export class SessionManager {
|
|
|
505
505
|
return undefined;
|
|
506
506
|
}
|
|
507
507
|
}
|
|
508
|
+
/** Cheap durable project-binding read; absent for legacy/no-repo Sessions. */
|
|
509
|
+
readSessionProjectBinding(sessionId) {
|
|
510
|
+
try {
|
|
511
|
+
assertSafeSessionId(sessionId);
|
|
512
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
513
|
+
const state = processLocal
|
|
514
|
+
? processLocal.state
|
|
515
|
+
: sessionId.startsWith("qchat-")
|
|
516
|
+
? undefined
|
|
517
|
+
: JSON.parse(readFileSync(join(this.sessionsDir, sessionId, "state.json"), "utf-8"));
|
|
518
|
+
const project = state?.project;
|
|
519
|
+
return project &&
|
|
520
|
+
typeof project.projectId === "string" &&
|
|
521
|
+
project.projectId.length > 0 &&
|
|
522
|
+
typeof project.mainRootId === "string" &&
|
|
523
|
+
project.mainRootId.length > 0
|
|
524
|
+
? { projectId: project.projectId, mainRootId: project.mainRootId }
|
|
525
|
+
: undefined;
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
508
531
|
/** Cheap durable classification read. Legacy sessions are ordinary work sessions. */
|
|
509
532
|
readSessionKind(sessionId) {
|
|
510
533
|
try {
|
|
@@ -686,6 +709,70 @@ export class SessionManager {
|
|
|
686
709
|
}
|
|
687
710
|
return this.updateSessionState(sessionId, { workspace });
|
|
688
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Commit a Session main-root migration as one state.json replacement.
|
|
714
|
+
*
|
|
715
|
+
* `cwd`, the durable project binding, and the execution workspace are one
|
|
716
|
+
* consistency unit: exposing any subset would let a resumed run combine the
|
|
717
|
+
* old authority with a new path. Derived availability such as dir_missing is
|
|
718
|
+
* deliberately absent from this patch and is recomputed by the host.
|
|
719
|
+
*/
|
|
720
|
+
migrateSessionMainRoot(sessionId, project, mainRoot) {
|
|
721
|
+
if (!project ||
|
|
722
|
+
typeof project.projectId !== "string" ||
|
|
723
|
+
project.projectId.length === 0 ||
|
|
724
|
+
typeof project.mainRootId !== "string" ||
|
|
725
|
+
project.mainRootId.length === 0 ||
|
|
726
|
+
typeof mainRoot !== "string" ||
|
|
727
|
+
mainRoot.length === 0) {
|
|
728
|
+
throw new SessionError(`invalid main-root migration for ${sessionId}`);
|
|
729
|
+
}
|
|
730
|
+
return this.updateSessionState(sessionId, {
|
|
731
|
+
project: { ...project },
|
|
732
|
+
cwd: mainRoot,
|
|
733
|
+
workspace: { root: mainRoot, kind: "main" },
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Commit a host-owned offline migration only if the durable snapshot Main
|
|
738
|
+
* just revalidated is still current. Unlike the ordinary field-level writer,
|
|
739
|
+
* this must not retry onto a newer revision: a new writer means ownership is
|
|
740
|
+
* no longer provably unchanged, so the migration fails closed.
|
|
741
|
+
*/
|
|
742
|
+
migrateSessionMainRootIfRevision(sessionId, project, mainRoot, expectedStateRevision) {
|
|
743
|
+
assertSafeSessionId(sessionId);
|
|
744
|
+
if (!project ||
|
|
745
|
+
typeof project.projectId !== "string" ||
|
|
746
|
+
project.projectId.length === 0 ||
|
|
747
|
+
typeof project.mainRootId !== "string" ||
|
|
748
|
+
project.mainRootId.length === 0 ||
|
|
749
|
+
typeof mainRoot !== "string" ||
|
|
750
|
+
mainRoot.length === 0) {
|
|
751
|
+
throw new SessionError(`invalid main-root migration for ${sessionId}`);
|
|
752
|
+
}
|
|
753
|
+
const state = this.readPersistedState(sessionId);
|
|
754
|
+
if (state.stateRevision !== expectedStateRevision) {
|
|
755
|
+
throw new SessionError(`Session state revision conflict for ${sessionId}`);
|
|
756
|
+
}
|
|
757
|
+
Object.assign(state, {
|
|
758
|
+
project: { ...project },
|
|
759
|
+
cwd: mainRoot,
|
|
760
|
+
workspace: { root: mainRoot, kind: "main" },
|
|
761
|
+
});
|
|
762
|
+
const result = this.saveStateAttempt(state);
|
|
763
|
+
if (result.ok)
|
|
764
|
+
return state.stateRevision;
|
|
765
|
+
if (result.reason === "generation_conflict") {
|
|
766
|
+
throw new SessionError(`Session generation conflict for ${sessionId}`);
|
|
767
|
+
}
|
|
768
|
+
if (result.reason === "lock_conflict") {
|
|
769
|
+
throw new SessionError(`Session state lock contention for ${sessionId}`);
|
|
770
|
+
}
|
|
771
|
+
if (result.reason === "kind_conflict") {
|
|
772
|
+
throw new SessionError(`Session kind is immutable for ${sessionId}`);
|
|
773
|
+
}
|
|
774
|
+
throw new SessionError(`Session state revision conflict for ${sessionId}`);
|
|
775
|
+
}
|
|
689
776
|
/** Read the durable archival timestamp; undefined = not archived / unprovable. */
|
|
690
777
|
readSessionArchivedAt(sessionId) {
|
|
691
778
|
try {
|
|
@@ -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>;
|