@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.
Files changed (74) hide show
  1. package/dist/automation/desktop-authority-client.d.ts +9 -0
  2. package/dist/automation/desktop-authority-client.js +47 -0
  3. package/dist/automation/scheduler.d.ts +8 -0
  4. package/dist/automation/scheduler.js +37 -1
  5. package/dist/automation/store.js +14 -1
  6. package/dist/capabilities/index.d.ts +1 -0
  7. package/dist/cli/agent-server-stdio.js +4 -1
  8. package/dist/engine/engine-workspace-authority.d.ts +50 -0
  9. package/dist/engine/engine-workspace-authority.js +167 -0
  10. package/dist/engine/engine.d.ts +7 -10
  11. package/dist/engine/engine.js +87 -101
  12. package/dist/engine/input-attachments.d.ts +1 -0
  13. package/dist/engine/input-attachments.js +6 -2
  14. package/dist/engine/run-environment.d.ts +8 -3
  15. package/dist/engine/run-environment.js +33 -8
  16. package/dist/engine/run-image-input.d.ts +1 -0
  17. package/dist/engine/run-image-input.js +1 -0
  18. package/dist/engine/run-session-open.d.ts +2 -1
  19. package/dist/engine/run-session-open.js +6 -0
  20. package/dist/engine/run-setup.d.ts +1 -0
  21. package/dist/engine/run-setup.js +2 -1
  22. package/dist/engine/run-tooling.d.ts +2 -0
  23. package/dist/engine/run-tooling.js +3 -0
  24. package/dist/engine/run-types.d.ts +2 -0
  25. package/dist/engine/run-workspace.d.ts +6 -1
  26. package/dist/engine/run-workspace.js +47 -0
  27. package/dist/engine/subagent-spawner.d.ts +1 -0
  28. package/dist/engine/subagent-spawner.js +1 -0
  29. package/dist/engine/types.d.ts +2 -0
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.extension.d.ts +1 -1
  32. package/dist/index.internal.d.ts +2 -2
  33. package/dist/index.internal.js +1 -0
  34. package/dist/index.js +1 -1
  35. package/dist/plugins/pluginAutomationTemplates.d.ts +2 -0
  36. package/dist/plugins/pluginAutomationTemplates.js +4 -0
  37. package/dist/prompt/composer.d.ts +4 -1
  38. package/dist/prompt/composer.js +11 -3
  39. package/dist/protocol/background-result-wakeup.d.ts +18 -0
  40. package/dist/protocol/background-result-wakeup.js +101 -0
  41. package/dist/protocol/chat-session-manager.d.ts +42 -1
  42. package/dist/protocol/chat-session-manager.js +167 -4
  43. package/dist/protocol/chat-session.d.ts +9 -1
  44. package/dist/protocol/chat-session.js +20 -2
  45. package/dist/protocol/mobile-remote-types.d.ts +15 -0
  46. package/dist/protocol/server.d.ts +5 -6
  47. package/dist/protocol/server.js +62 -128
  48. package/dist/protocol/session-workspace-rpc.d.ts +23 -0
  49. package/dist/protocol/session-workspace-rpc.js +171 -0
  50. package/dist/protocol/types.d.ts +45 -1
  51. package/dist/protocol/types.js +4 -0
  52. package/dist/session/session-manager.d.ts +18 -0
  53. package/dist/session/session-manager.js +87 -0
  54. package/dist/settings/manager.d.ts +7 -0
  55. package/dist/settings/manager.js +64 -3
  56. package/dist/tool-system/builtin/agent-notifications.d.ts +8 -0
  57. package/dist/tool-system/builtin/agent-notifications.js +19 -0
  58. package/dist/tool-system/builtin/config.d.ts +11 -0
  59. package/dist/tool-system/builtin/config.js +55 -10
  60. package/dist/tool-system/builtin/cron.d.ts +11 -0
  61. package/dist/tool-system/builtin/cron.js +27 -2
  62. package/dist/tool-system/builtin/edit.js +5 -3
  63. package/dist/tool-system/builtin/view-image.js +8 -2
  64. package/dist/tool-system/builtin/write.js +5 -3
  65. package/dist/tool-system/context.d.ts +4 -1
  66. package/dist/tool-system/executor.js +1 -1
  67. package/dist/tool-system/path-policy.d.ts +11 -3
  68. package/dist/tool-system/path-policy.js +56 -36
  69. package/dist/types.d.ts +6 -0
  70. package/dist/workspace/canonical-key.d.ts +7 -0
  71. package/dist/workspace/canonical-key.js +39 -0
  72. package/dist/workspace/workspace-context.d.ts +35 -0
  73. package/dist/workspace/workspace-context.js +128 -0
  74. 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
+ }
@@ -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. */
@@ -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
  /**
@@ -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>;