@cjhyy/code-shell-core 0.6.0-rc.9 → 0.7.0-beta.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 (168) hide show
  1. package/THIRD_PARTY_NOTICES.md +206 -0
  2. package/dist/automation/scheduler.d.ts +13 -7
  3. package/dist/automation/scheduler.js +116 -37
  4. package/dist/capability-control/service.d.ts +2 -0
  5. package/dist/capability-control/service.js +4 -2
  6. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  7. package/dist/cc-orchestrator/agent-adapter.js +7 -1
  8. package/dist/cc-orchestrator/codex-session-history.js +1 -1
  9. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  10. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  11. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  12. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  13. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  14. package/dist/cc-orchestrator/external-agent-driver.js +102 -51
  15. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  16. package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
  17. package/dist/cc-orchestrator/session-history.js +2 -2
  18. package/dist/cli/agent-server-stdio.js +9 -2
  19. package/dist/context/compaction.d.ts +8 -1
  20. package/dist/context/compaction.js +49 -4
  21. package/dist/context/manager.d.ts +16 -2
  22. package/dist/context/manager.js +103 -19
  23. package/dist/credentials/access.d.ts +56 -0
  24. package/dist/credentials/access.js +183 -0
  25. package/dist/credentials/index.d.ts +1 -0
  26. package/dist/credentials/index.js +1 -0
  27. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  28. package/dist/credentials/inject-credential-tool.js +30 -11
  29. package/dist/credentials/types.d.ts +2 -2
  30. package/dist/credentials/use-credential-tool.d.ts +9 -1
  31. package/dist/credentials/use-credential-tool.js +58 -45
  32. package/dist/engine/engine.d.ts +17 -1
  33. package/dist/engine/engine.js +247 -89
  34. package/dist/engine/image-policy.d.ts +6 -0
  35. package/dist/engine/image-policy.js +17 -6
  36. package/dist/engine/input-attachments.d.ts +13 -0
  37. package/dist/engine/input-attachments.js +255 -0
  38. package/dist/engine/model-facade.d.ts +5 -2
  39. package/dist/engine/model-facade.js +4 -4
  40. package/dist/engine/parse-task.d.ts +10 -0
  41. package/dist/engine/parse-task.js +5 -0
  42. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  43. package/dist/engine/streaming-tool-queue.js +11 -7
  44. package/dist/engine/turn-loop.d.ts +7 -1
  45. package/dist/engine/turn-loop.js +117 -27
  46. package/dist/engine/types.d.ts +8 -0
  47. package/dist/git/worktree/crud.d.ts +69 -0
  48. package/dist/git/worktree/crud.js +206 -0
  49. package/dist/git/worktree/diff.d.ts +14 -0
  50. package/dist/git/worktree/diff.js +82 -0
  51. package/dist/git/worktree/git-exec.d.ts +7 -0
  52. package/dist/git/worktree/git-exec.js +51 -0
  53. package/dist/git/worktree/index.d.ts +5 -0
  54. package/dist/git/worktree/index.js +5 -0
  55. package/dist/git/worktree/query.d.ts +42 -0
  56. package/dist/git/worktree/query.js +121 -0
  57. package/dist/git/worktree/slug.d.ts +11 -0
  58. package/dist/git/worktree/slug.js +58 -0
  59. package/dist/git/worktree.d.ts +1 -84
  60. package/dist/git/worktree.js +5 -230
  61. package/dist/index.d.ts +31 -28
  62. package/dist/index.js +27 -25
  63. package/dist/logging/logger.js +6 -6
  64. package/dist/logging/sanitize-messages.d.ts +10 -2
  65. package/dist/logging/sanitize-messages.js +21 -6
  66. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  67. package/dist/plugins/installer/checkUpdate.js +4 -2
  68. package/dist/plugins/installer/install.js +2 -0
  69. package/dist/plugins/installer/installFromSource.js +9 -1
  70. package/dist/plugins/installer/parseSource.d.ts +4 -1
  71. package/dist/plugins/installer/parseSource.js +28 -10
  72. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  73. package/dist/plugins/installer/sourcePath.js +50 -0
  74. package/dist/plugins/installer/update.d.ts +4 -1
  75. package/dist/plugins/installer/update.js +5 -3
  76. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  77. package/dist/plugins/parseMarketplaceInput.js +4 -3
  78. package/dist/plugins/pluginInstaller.js +24 -22
  79. package/dist/preset/index.d.ts +1 -0
  80. package/dist/preset/index.js +16 -9
  81. package/dist/prompt/sections/base.md +1 -1
  82. package/dist/protocol/chat-session-manager.d.ts +2 -0
  83. package/dist/protocol/chat-session-manager.js +38 -2
  84. package/dist/protocol/chat-session.d.ts +3 -0
  85. package/dist/protocol/chat-session.js +1 -0
  86. package/dist/protocol/client.d.ts +9 -4
  87. package/dist/protocol/client.js +18 -1
  88. package/dist/protocol/server.d.ts +30 -0
  89. package/dist/protocol/server.js +246 -46
  90. package/dist/protocol/types.d.ts +47 -0
  91. package/dist/protocol/types.js +6 -0
  92. package/dist/run/FileRunStore.js +10 -1
  93. package/dist/run/Heartbeat.js +12 -0
  94. package/dist/run/RunApprovalBackend.d.ts +3 -0
  95. package/dist/run/RunApprovalBackend.js +41 -6
  96. package/dist/run/RunLock.js +2 -0
  97. package/dist/run/RunManager.d.ts +2 -0
  98. package/dist/run/RunManager.js +64 -24
  99. package/dist/run/ids.d.ts +2 -0
  100. package/dist/run/ids.js +23 -0
  101. package/dist/runtime/background-shell.d.ts +1 -0
  102. package/dist/runtime/background-shell.js +23 -13
  103. package/dist/runtime/spawn-common.js +10 -0
  104. package/dist/services/auto-dream.d.ts +15 -4
  105. package/dist/services/auto-dream.js +20 -20
  106. package/dist/services/dream-consolidation.d.ts +2 -3
  107. package/dist/services/dream-consolidation.js +65 -15
  108. package/dist/services/extract-memories.d.ts +14 -5
  109. package/dist/services/extract-memories.js +20 -3
  110. package/dist/services/global-dream-promotion.d.ts +23 -0
  111. package/dist/services/global-dream-promotion.js +112 -0
  112. package/dist/services/memory-orchestrator.js +347 -34
  113. package/dist/session/memory.d.ts +61 -18
  114. package/dist/session/memory.js +342 -79
  115. package/dist/session/session-manager.d.ts +35 -1
  116. package/dist/session/session-manager.js +190 -3
  117. package/dist/session/transcript.d.ts +1 -1
  118. package/dist/session/transcript.js +17 -5
  119. package/dist/settings/manager.d.ts +1 -0
  120. package/dist/settings/manager.js +87 -37
  121. package/dist/settings/schema-export.d.ts +2 -3
  122. package/dist/settings/schema-export.js +2 -3
  123. package/dist/settings/schema.d.ts +21 -0
  124. package/dist/settings/schema.js +12 -0
  125. package/dist/skills/scanner.d.ts +3 -2
  126. package/dist/skills/scanner.js +12 -9
  127. package/dist/tool-system/builtin/background-jobs.d.ts +10 -1
  128. package/dist/tool-system/builtin/background-jobs.js +12 -1
  129. package/dist/tool-system/builtin/background-work.d.ts +17 -18
  130. package/dist/tool-system/builtin/background-work.js +51 -5
  131. package/dist/tool-system/builtin/config.d.ts +2 -1
  132. package/dist/tool-system/builtin/config.js +16 -11
  133. package/dist/tool-system/builtin/drive-claude-code.d.ts +16 -2
  134. package/dist/tool-system/builtin/drive-claude-code.js +301 -47
  135. package/dist/tool-system/builtin/edit.js +5 -2
  136. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  137. package/dist/tool-system/builtin/generate-video.js +13 -4
  138. package/dist/tool-system/builtin/index.d.ts +8 -3
  139. package/dist/tool-system/builtin/index.js +37 -21
  140. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  141. package/dist/tool-system/builtin/lsp.js +6 -3
  142. package/dist/tool-system/builtin/memory.js +40 -8
  143. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  144. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  145. package/dist/tool-system/builtin/powershell.js +11 -7
  146. package/dist/tool-system/builtin/read.js +118 -6
  147. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  148. package/dist/tool-system/builtin/view-image.js +109 -19
  149. package/dist/tool-system/builtin/worktree.d.ts +4 -4
  150. package/dist/tool-system/builtin/worktree.js +297 -74
  151. package/dist/tool-system/builtin/write.js +5 -3
  152. package/dist/tool-system/context.d.ts +23 -1
  153. package/dist/tool-system/executor.d.ts +1 -5
  154. package/dist/tool-system/executor.js +94 -115
  155. package/dist/tool-system/mcp-manager.d.ts +18 -5
  156. package/dist/tool-system/mcp-manager.js +140 -74
  157. package/dist/tool-system/path-policy.d.ts +2 -0
  158. package/dist/tool-system/path-policy.js +41 -12
  159. package/dist/tool-system/permission.d.ts +28 -7
  160. package/dist/tool-system/permission.js +130 -49
  161. package/dist/tool-system/registry.js +11 -4
  162. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  163. package/dist/tool-system/tool-result-redaction.js +48 -0
  164. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  165. package/dist/tool-system/workspace-bridge.js +1 -0
  166. package/dist/types.d.ts +55 -4
  167. package/dist/utils/toolDisplay.js +1 -1
  168. package/package.json +4 -3
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Session lifecycle manager.
3
3
  */
4
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
4
+ import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { nanoid } from "nanoid";
8
8
  import { Transcript } from "./transcript.js";
9
9
  import { SessionError } from "../exceptions.js";
10
10
  import { normalizeCumulativeUsageCounters } from "../engine/session-usage.js";
11
+ import { branchExists, isGitWorktreeRoot } from "../git/worktree.js";
11
12
  /**
12
13
  * Validate a session ID before it is joined into a filesystem path.
13
14
  *
@@ -55,6 +56,44 @@ export function assertSafeSessionId(sessionId) {
55
56
  export function codeShellHome() {
56
57
  return process.env.CODE_SHELL_HOME || join(homedir(), ".code-shell");
57
58
  }
59
+ function isSessionWorkspace(value) {
60
+ if (!value || typeof value !== "object")
61
+ return false;
62
+ const ws = value;
63
+ if (typeof ws.root !== "string" || ws.root.length === 0)
64
+ return false;
65
+ if (ws.kind !== "main" && ws.kind !== "worktree")
66
+ return false;
67
+ if (ws.kind === "main")
68
+ return ws.worktree === undefined;
69
+ const wt = ws.worktree;
70
+ return (!!wt &&
71
+ typeof wt.path === "string" &&
72
+ wt.path.length > 0 &&
73
+ typeof wt.branch === "string" &&
74
+ wt.branch.length > 0 &&
75
+ typeof wt.baseRef === "string" &&
76
+ wt.baseRef.length > 0 &&
77
+ wt.createdBy === "codeshell");
78
+ }
79
+ async function validateResumeWorktreeRoot(root) {
80
+ if (!existsSync(root))
81
+ return "no longer exists";
82
+ let stat;
83
+ try {
84
+ stat = lstatSync(root);
85
+ }
86
+ catch {
87
+ return "no longer exists";
88
+ }
89
+ if (stat.isSymbolicLink())
90
+ return "is not a valid git worktree (symbolic link)";
91
+ if (!stat.isDirectory())
92
+ return "is not a valid git worktree (not a directory)";
93
+ if (!(await isGitWorktreeRoot(root)))
94
+ return "is not a valid git worktree";
95
+ return null;
96
+ }
58
97
  export class SessionManager {
59
98
  sessionsDir;
60
99
  constructor(storageDir) {
@@ -76,10 +115,19 @@ export class SessionManager {
76
115
  assertSafeSessionId(explicitSessionId);
77
116
  const sessionId = explicitSessionId ?? nanoid(16);
78
117
  const sessionDir = join(this.sessionsDir, sessionId);
79
- mkdirSync(sessionDir, { recursive: true });
118
+ try {
119
+ mkdirSync(sessionDir);
120
+ }
121
+ catch (err) {
122
+ if (err.code === "EEXIST") {
123
+ throw new SessionError(`Session already exists: ${sessionId}`);
124
+ }
125
+ throw err;
126
+ }
80
127
  const state = {
81
128
  sessionId,
82
129
  cwd,
130
+ workspace: { root: cwd, kind: "main" },
83
131
  startedAt: Date.now(),
84
132
  model,
85
133
  provider,
@@ -158,6 +206,143 @@ export class SessionManager {
158
206
  return undefined;
159
207
  }
160
208
  }
209
+ /**
210
+ * Disk-only workspace pointer reader. Legacy sessions written before
211
+ * `workspace` existed are treated as main-workspace sessions rooted at
212
+ * `state.cwd`; the read is intentionally non-mutating.
213
+ */
214
+ getSessionWorkspace(sessionId) {
215
+ try {
216
+ assertSafeSessionId(sessionId);
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
222
+ if (!existsSync(stateFile))
223
+ return undefined;
224
+ try {
225
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
226
+ if (isSessionWorkspace(state.workspace))
227
+ return state.workspace;
228
+ return typeof state.cwd === "string" && state.cwd.length > 0
229
+ ? { root: state.cwd, kind: "main" }
230
+ : undefined;
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ }
236
+ /**
237
+ * Persist the current session workspace pointer without changing legacy
238
+ * `cwd`. P1 will teach ToolContext to resolve cwd from this field; for P0 it
239
+ * is a safety pointer and resume breadcrumb.
240
+ */
241
+ setSessionWorkspace(sessionId, workspace) {
242
+ assertSafeSessionId(sessionId);
243
+ if (!isSessionWorkspace(workspace)) {
244
+ throw new SessionError(`invalid workspace for session ${sessionId}`);
245
+ }
246
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
247
+ if (!existsSync(stateFile)) {
248
+ throw new SessionError(`Session state file not found: ${sessionId}`);
249
+ }
250
+ let state;
251
+ try {
252
+ state = JSON.parse(readFileSync(stateFile, "utf-8"));
253
+ }
254
+ catch (err) {
255
+ throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
256
+ }
257
+ state.workspace = workspace;
258
+ this.saveState(state);
259
+ }
260
+ recordWorkspaceHandoff(sessionId, from, to) {
261
+ assertSafeSessionId(sessionId);
262
+ const transcriptFile = join(this.sessionsDir, sessionId, "transcript.jsonl");
263
+ if (!existsSync(transcriptFile))
264
+ return;
265
+ try {
266
+ const transcript = new Transcript(transcriptFile);
267
+ transcript.append("session_meta", {
268
+ sessionId,
269
+ cwd: to.root,
270
+ workspace: to,
271
+ handoffFrom: from?.root,
272
+ handoffAt: Date.now(),
273
+ });
274
+ }
275
+ catch {
276
+ // Transcript handoff metadata is best-effort; state.workspace is the
277
+ // authoritative switch pointer.
278
+ }
279
+ }
280
+ /**
281
+ * Resolve the cwd a resumed session must run in from its persisted workspace
282
+ * pointer. A missing worktree directory is never silently treated as the main
283
+ * repo: if the branch still exists callers get a blocking recreate message;
284
+ * if the branch is gone the workspace pointer is reset to main and a warning
285
+ * message is returned for the host to surface before continuing.
286
+ */
287
+ async resolveSessionWorkspaceForResume(sessionId) {
288
+ assertSafeSessionId(sessionId);
289
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
290
+ if (!existsSync(stateFile)) {
291
+ throw new SessionError(`Session state file not found: ${sessionId}`);
292
+ }
293
+ let state;
294
+ try {
295
+ state = JSON.parse(readFileSync(stateFile, "utf-8"));
296
+ }
297
+ catch (err) {
298
+ throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
299
+ }
300
+ const legacyMain = typeof state.cwd === "string" && state.cwd.length > 0
301
+ ? { root: state.cwd, kind: "main" }
302
+ : undefined;
303
+ const workspace = isSessionWorkspace(state.workspace) ? state.workspace : legacyMain;
304
+ if (!workspace) {
305
+ throw new SessionError(`Session ${sessionId} has no recoverable cwd`);
306
+ }
307
+ if (workspace.kind === "main") {
308
+ return {
309
+ ok: true,
310
+ cwd: workspace.root,
311
+ workspace,
312
+ reason: isSessionWorkspace(state.workspace) ? "main" : "legacy",
313
+ };
314
+ }
315
+ const invalidWorktreeReason = await validateResumeWorktreeRoot(workspace.root);
316
+ if (!invalidWorktreeReason) {
317
+ return { ok: true, cwd: workspace.root, workspace, reason: "worktree" };
318
+ }
319
+ const branch = workspace.worktree?.branch;
320
+ const mainRoot = typeof state.cwd === "string" && state.cwd.length > 0 ? state.cwd : workspace.root;
321
+ if (branch && existsSync(mainRoot) && (await branchExists(mainRoot, branch))) {
322
+ return {
323
+ ok: false,
324
+ cwd: mainRoot,
325
+ workspace,
326
+ reason: "worktree_missing_branch_exists",
327
+ message: `Session ${sessionId} is bound to worktree ${workspace.root}, but that directory ` +
328
+ `${invalidWorktreeReason}. Branch ${branch} still exists; recreate the worktree at ` +
329
+ `${workspace.worktree?.path ?? workspace.root} before resuming, or switch this session ` +
330
+ `back to main explicitly.`,
331
+ };
332
+ }
333
+ const fallback = { root: mainRoot, kind: "main" };
334
+ state.workspace = fallback;
335
+ this.saveState(state);
336
+ return {
337
+ ok: true,
338
+ cwd: mainRoot,
339
+ workspace: fallback,
340
+ reason: "worktree_missing_branch_gone",
341
+ message: `Session ${sessionId} was bound to worktree ${workspace.root}, but that directory ` +
342
+ `${invalidWorktreeReason}${branch ? ` and branch ${branch} is gone` : ""}; fell back to main ` +
343
+ `${mainRoot}. Re-run the request if you want to continue there.`,
344
+ };
345
+ }
161
346
  /**
162
347
  * Cheap "does this session have a persisted goal?" probe — reads only
163
348
  * state.json, NOT the transcript (like readCwd). A persistent goal lives ONLY
@@ -302,7 +487,7 @@ export class SessionManager {
302
487
  continue;
303
488
  const transcriptFile = join(this.sessionsDir, dir, "transcript.jsonl");
304
489
  let lastActiveAt;
305
- let transcriptExists = false;
490
+ let transcriptExists;
306
491
  try {
307
492
  transcriptExists = existsSync(transcriptFile);
308
493
  if (transcriptExists) {
@@ -436,6 +621,8 @@ function parseUserPreview(line) {
436
621
  return undefined;
437
622
  if (event.data?.role !== "user")
438
623
  return undefined;
624
+ if (event.data.injected === true)
625
+ return undefined;
439
626
  const content = event.data.content;
440
627
  const text = typeof content === "string"
441
628
  ? content
@@ -27,7 +27,7 @@ export declare class Transcript {
27
27
  }): TranscriptEvent;
28
28
  hasClientMessageId(clientMessageId: string): boolean;
29
29
  appendToolUse(toolName: string, toolCallId: string, args: Record<string, unknown>): TranscriptEvent;
30
- appendToolResult(toolCallId: string, toolName: string, result?: string, error?: string): TranscriptEvent;
30
+ appendToolResult(toolCallId: string, toolName: string, result?: string, error?: string, contentBlocks?: ContentBlock[]): TranscriptEvent;
31
31
  /** Anchor for a spawned sub-agent (see TranscriptEventType "subagent").
32
32
  * Written at spawn time so replay can rebuild the sub-agent's card from
33
33
  * sessions/<agentId>/ — agentId === the sub-agent's session id. */
@@ -10,7 +10,9 @@ export class Transcript {
10
10
  events = [];
11
11
  filePath;
12
12
  currentTurn = 0;
13
- getFilePath() { return this.filePath; }
13
+ getFilePath() {
14
+ return this.filePath;
15
+ }
14
16
  constructor(filePath) {
15
17
  this.filePath = filePath;
16
18
  mkdirSync(dirname(filePath), { recursive: true });
@@ -66,8 +68,14 @@ export class Transcript {
66
68
  appendToolUse(toolName, toolCallId, args) {
67
69
  return this.append("tool_use", { toolName, toolCallId, args });
68
70
  }
69
- appendToolResult(toolCallId, toolName, result, error) {
70
- return this.append("tool_result", { toolCallId, toolName, result, error });
71
+ appendToolResult(toolCallId, toolName, result, error, contentBlocks) {
72
+ return this.append("tool_result", {
73
+ toolCallId,
74
+ toolName,
75
+ result,
76
+ error,
77
+ ...(contentBlocks && contentBlocks.length > 0 ? { contentBlocks } : {}),
78
+ });
71
79
  }
72
80
  /** Anchor for a spawned sub-agent (see TranscriptEventType "subagent").
73
81
  * Written at spawn time so replay can rebuild the sub-agent's card from
@@ -125,13 +133,17 @@ export class Transcript {
125
133
  break;
126
134
  }
127
135
  case "tool_result": {
128
- const { toolCallId, result, error } = event.data;
136
+ const { toolCallId, result, error, contentBlocks } = event.data;
129
137
  // Find if there's already a user message with tool_results to append to
130
138
  const lastMsg = messages[messages.length - 1];
131
139
  const block = {
132
140
  type: "tool_result",
133
141
  tool_use_id: toolCallId,
134
- content: error ? `Error: ${error}` : result ?? "(no output)",
142
+ content: error
143
+ ? `Error: ${error}`
144
+ : Array.isArray(contentBlocks) && contentBlocks.length > 0
145
+ ? contentBlocks
146
+ : (result ?? "(no output)"),
135
147
  };
136
148
  if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
137
149
  lastMsg.content.push(block);
@@ -18,6 +18,7 @@ export declare function userHome(): string;
18
18
  * ("permissions.defaultMode", "env.FOO") are caught too.
19
19
  */
20
20
  export declare function isProtectedSettingKey(key: string): boolean;
21
+ export declare function setDottedSetting(target: Record<string, unknown>, key: string, value: unknown): void;
21
22
  /**
22
23
  * The fixed cwd used for "no-repo" pure-chat conversations (a chat not bound to
23
24
  * any code project). Same location as desktop's `resolveNoRepoCwd`
@@ -50,6 +50,66 @@ export function isProtectedSettingKey(key) {
50
50
  const root = key.split(".")[0] ?? "";
51
51
  return PROTECTED_SETTING_ROOTS.has(root);
52
52
  }
53
+ const FORBIDDEN_SETTING_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
54
+ function isForbiddenSettingKeySegment(key) {
55
+ return FORBIDDEN_SETTING_KEY_SEGMENTS.has(key);
56
+ }
57
+ function parseDottedSettingKey(key) {
58
+ const parts = key.split(".");
59
+ if (parts.length === 0 ||
60
+ parts.some((seg) => seg.length === 0 || isForbiddenSettingKeySegment(seg))) {
61
+ throw new Error(`invalid setting key: ${key}`);
62
+ }
63
+ return parts;
64
+ }
65
+ function sanitizeSettingsValue(value) {
66
+ if (Array.isArray(value)) {
67
+ return value.map((entry) => sanitizeSettingsValue(entry));
68
+ }
69
+ if (!value || typeof value !== "object") {
70
+ return value;
71
+ }
72
+ const out = {};
73
+ for (const [key, child] of Object.entries(value)) {
74
+ if (isForbiddenSettingKeySegment(key))
75
+ continue;
76
+ out[key] = sanitizeSettingsValue(child);
77
+ }
78
+ return out;
79
+ }
80
+ function sanitizeSettingsObject(data) {
81
+ return sanitizeSettingsValue(data);
82
+ }
83
+ function isOwnPlainObject(parent, key) {
84
+ if (!Object.prototype.hasOwnProperty.call(parent, key))
85
+ return false;
86
+ const value = parent[key];
87
+ if (!value || typeof value !== "object" || Array.isArray(value))
88
+ return false;
89
+ const proto = Object.getPrototypeOf(value);
90
+ return proto === Object.prototype || proto === null;
91
+ }
92
+ function descendForSettingWrite(target, key, fullKey) {
93
+ if (!Object.prototype.hasOwnProperty.call(target, key)) {
94
+ const inherited = target[key];
95
+ if (inherited && typeof inherited === "object") {
96
+ throw new Error(`invalid setting key: ${fullKey} (refusing to descend through inherited object segment: ${key})`);
97
+ }
98
+ target[key] = {};
99
+ }
100
+ else if (!isOwnPlainObject(target, key)) {
101
+ target[key] = {};
102
+ }
103
+ return target[key];
104
+ }
105
+ export function setDottedSetting(target, key, value) {
106
+ const parts = parseDottedSettingKey(key);
107
+ let current = target;
108
+ for (let i = 0; i < parts.length - 1; i++) {
109
+ current = descendForSettingWrite(current, parts[i], key);
110
+ }
111
+ current[parts[parts.length - 1]] = value;
112
+ }
53
113
  /**
54
114
  * The fixed cwd used for "no-repo" pure-chat conversations (a chat not bound to
55
115
  * any code project). Same location as desktop's `resolveNoRepoCwd`
@@ -135,7 +195,7 @@ export class SettingsManager {
135
195
  }
136
196
  // 5. CLI flags (highest priority)
137
197
  if (flagOverrides && Object.keys(flagOverrides).length > 0) {
138
- this.sources.push({ name: "flag", priority: 4, data: flagOverrides });
198
+ this.sources.push({ name: "flag", priority: 4, data: sanitizeSettingsObject(flagOverrides) });
139
199
  }
140
200
  // Sort by priority ascending (merge in order, later wins)
141
201
  this.sources.sort((a, b) => a.priority - b.priority);
@@ -176,7 +236,7 @@ export class SettingsManager {
176
236
  const userPath = join(userHome(), ".code-shell", "settings.json");
177
237
  if (readUser && existsSync(userPath)) {
178
238
  try {
179
- const userRaw = JSON.parse(readFileSync(userPath, "utf-8"));
239
+ const userRaw = sanitizeSettingsObject(JSON.parse(readFileSync(userPath, "utf-8")));
180
240
  const result = migrateModels({
181
241
  providers: userRaw.providers ?? [],
182
242
  models: userRaw.models ?? [],
@@ -188,14 +248,15 @@ export class SettingsManager {
188
248
  providers: result.providers,
189
249
  models: result.models,
190
250
  };
251
+ const sanitized = sanitizeSettingsObject(migrated);
191
252
  // Atomic write (tmp+rename) — a concurrent load must not see a
192
253
  // half-written file. File exists here (existsSync guard above).
193
- this.atomicWriteJson(userPath, migrated);
254
+ this.atomicWriteJson(userPath, sanitized);
194
255
  // Re-deep-merge with the migrated user data so the validate
195
256
  // call sees the new shape rather than the legacy one.
196
257
  const userSource = this.sources.find((s) => s.name === "user");
197
258
  if (userSource)
198
- userSource.data = migrated;
259
+ userSource.data = sanitized;
199
260
  const remerged = this.deepMerge();
200
261
  this.merged = validateSettings(remerged);
201
262
  return this.merged;
@@ -224,7 +285,7 @@ export class SettingsManager {
224
285
  const parsed = JSON.parse(readFileSync(path, "utf-8"));
225
286
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
226
287
  return;
227
- const raw = parsed;
288
+ const raw = sanitizeSettingsObject(parsed);
228
289
  const result = migrateConfig(raw);
229
290
  if (!result.changed)
230
291
  return;
@@ -240,10 +301,11 @@ export class SettingsManager {
240
301
  // Atomic write (tmp+rename) so a concurrent load can't read a half-written
241
302
  // migrated file — matches the normal save path (atomicWriteJson). The file
242
303
  // exists here (existsSync guard above), so the recursive mkdir is a no-op.
243
- this.atomicWriteJson(path, result.config);
304
+ const sanitized = sanitizeSettingsObject(result.config);
305
+ this.atomicWriteJson(path, sanitized);
244
306
  const source = this.sources.find((s) => s.name === sourceName);
245
307
  if (source)
246
- source.data = result.config;
308
+ source.data = sanitized;
247
309
  }
248
310
  catch {
249
311
  // Best-effort — fall through to normal merge/validate.
@@ -279,24 +341,14 @@ export class SettingsManager {
279
341
  const raw = readFileSync(path, "utf-8");
280
342
  const parsed = JSON.parse(raw);
281
343
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
282
- current = parsed;
344
+ current = sanitizeSettingsObject(parsed);
283
345
  }
284
346
  }
285
347
  catch {
286
348
  // Corrupt file — overwrite rather than crash.
287
349
  }
288
350
  }
289
- const parts = key.split(".");
290
- let target = current;
291
- for (let i = 0; i < parts.length - 1; i++) {
292
- const seg = parts[i];
293
- const next = target[seg];
294
- if (!next || typeof next !== "object" || Array.isArray(next)) {
295
- target[seg] = {};
296
- }
297
- target = target[seg];
298
- }
299
- target[parts[parts.length - 1]] = value;
351
+ setDottedSetting(current, key, value);
300
352
  mkdirSync(dirname(path), { recursive: true });
301
353
  // Atomic write: stage to .tmp, then rename, so a concurrent read can't
302
354
  // catch a half-written file. mode 0o600 — settings.json can hold plaintext
@@ -323,17 +375,7 @@ export class SettingsManager {
323
375
  if (!existsSync(cwd))
324
376
  return;
325
377
  const current = this.readJsonObject(path);
326
- const parts = key.split(".");
327
- let target = current;
328
- for (let i = 0; i < parts.length - 1; i++) {
329
- const seg = parts[i];
330
- const next = target[seg];
331
- if (!next || typeof next !== "object" || Array.isArray(next)) {
332
- target[seg] = {};
333
- }
334
- target = target[seg];
335
- }
336
- target[parts[parts.length - 1]] = value;
378
+ setDottedSetting(current, key, value);
337
379
  this.atomicWriteJson(path, current);
338
380
  this.invalidate();
339
381
  }
@@ -353,13 +395,13 @@ export class SettingsManager {
353
395
  if (!resolveConfigPath(path))
354
396
  return;
355
397
  const current = this.readJsonObject(path);
356
- const parts = key.split(".");
398
+ const parts = parseDottedSettingKey(key);
357
399
  let target = current;
358
400
  for (let i = 0; i < parts.length - 1; i++) {
359
- const next = target?.[parts[i]];
360
- if (!next || typeof next !== "object" || Array.isArray(next))
401
+ const seg = parts[i];
402
+ if (!target || !isOwnPlainObject(target, seg))
361
403
  return;
362
- target = next;
404
+ target = target[seg];
363
405
  }
364
406
  if (target)
365
407
  delete target[parts[parts.length - 1]];
@@ -481,7 +523,7 @@ function parseConfigFile(path) {
481
523
  const ext = extname(path).toLowerCase();
482
524
  const parsed = ext === ".yaml" || ext === ".yml" ? parseYaml(content) : JSON.parse(content);
483
525
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
484
- return parsed;
526
+ return sanitizeSettingsObject(parsed);
485
527
  }
486
528
  }
487
529
  catch {
@@ -508,9 +550,17 @@ function resolveConfigPath(jsonPath) {
508
550
  return null;
509
551
  }
510
552
  function merge(base, override) {
511
- const result = { ...base };
553
+ const result = {};
554
+ for (const [key, value] of Object.entries(base)) {
555
+ if (isForbiddenSettingKeySegment(key))
556
+ continue;
557
+ result[key] = value;
558
+ }
512
559
  for (const [key, value] of Object.entries(override)) {
513
- if (value === null) {
560
+ if (isForbiddenSettingKeySegment(key)) {
561
+ continue;
562
+ }
563
+ else if (value === null) {
514
564
  delete result[key];
515
565
  }
516
566
  else if (typeof value === "object" &&
@@ -7,9 +7,8 @@
7
7
  *
8
8
  * This module is side-effect free: it only EXPOSES the generator and a writer.
9
9
  * `manager.load()` deliberately does NOT call these — emitting a file during
10
- * load would pollute test HOMEs and add disk I/O to every boot. A host that
11
- * wants the file on disk should call `writeSettingsSchemaFile()` explicitly
12
- * (wiring TBD — see the task return note).
10
+ * load would pollute test HOMEs and add disk I/O to every settings read. Hosts
11
+ * write it explicitly during startup as a best-effort editor aid.
13
12
  */
14
13
  /**
15
14
  * Generate the JSON Schema for the settings object. The `name` option makes
@@ -7,9 +7,8 @@
7
7
  *
8
8
  * This module is side-effect free: it only EXPOSES the generator and a writer.
9
9
  * `manager.load()` deliberately does NOT call these — emitting a file during
10
- * load would pollute test HOMEs and add disk I/O to every boot. A host that
11
- * wants the file on disk should call `writeSettingsSchemaFile()` explicitly
12
- * (wiring TBD — see the task return note).
10
+ * load would pollute test HOMEs and add disk I/O to every settings read. Hosts
11
+ * write it explicitly during startup as a best-effort editor aid.
13
12
  */
14
13
  import { mkdirSync, renameSync, writeFileSync } from "node:fs";
15
14
  import { dirname, join } from "node:path";
@@ -366,6 +366,13 @@ export declare const SettingsSchema: z.ZodObject<{
366
366
  storageDir?: string | undefined;
367
367
  maxHistory?: number | undefined;
368
368
  }>>;
369
+ worktree: z.ZodDefault<z.ZodObject<{
370
+ branchPrefix: z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
371
+ }, "strip", z.ZodTypeAny, {
372
+ branchPrefix: string;
373
+ }, {
374
+ branchPrefix?: string | undefined;
375
+ }>>;
369
376
  mcpServers: z.ZodDefault<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodObject<{
370
377
  name: z.ZodString;
371
378
  command: z.ZodOptional<z.ZodString>;
@@ -1240,6 +1247,13 @@ export declare const SettingsSchema: z.ZodObject<{
1240
1247
  storageDir?: string | undefined;
1241
1248
  maxHistory?: number | undefined;
1242
1249
  }>>;
1250
+ worktree: z.ZodDefault<z.ZodObject<{
1251
+ branchPrefix: z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
1252
+ }, "strip", z.ZodTypeAny, {
1253
+ branchPrefix: string;
1254
+ }, {
1255
+ branchPrefix?: string | undefined;
1256
+ }>>;
1243
1257
  mcpServers: z.ZodDefault<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodObject<{
1244
1258
  name: z.ZodString;
1245
1259
  command: z.ZodOptional<z.ZodString>;
@@ -2114,6 +2128,13 @@ export declare const SettingsSchema: z.ZodObject<{
2114
2128
  storageDir?: string | undefined;
2115
2129
  maxHistory?: number | undefined;
2116
2130
  }>>;
2131
+ worktree: z.ZodDefault<z.ZodObject<{
2132
+ branchPrefix: z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
2133
+ }, "strip", z.ZodTypeAny, {
2134
+ branchPrefix: string;
2135
+ }, {
2136
+ branchPrefix?: string | undefined;
2137
+ }>>;
2117
2138
  mcpServers: z.ZodDefault<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodObject<{
2118
2139
  name: z.ZodString;
2119
2140
  command: z.ZodOptional<z.ZodString>;
@@ -2,6 +2,7 @@
2
2
  * Settings schema with Zod validation.
3
3
  */
4
4
  import { z } from "zod";
5
+ import { DEFAULT_WORKTREE_BRANCH_PREFIX, isValidWorktreeBranchPrefix, normalizeWorktreeBranchPrefix, } from "../git/worktree/slug.js";
5
6
  /**
6
7
  * Tri-state project capability overlay. Lives in PROJECT settings only and
7
8
  * layers over the global baseline (disabledSkills / disabledPlugins /
@@ -30,6 +31,12 @@ export const CapabilityOverridesSchema = z
30
31
  pluginHooks: z.record(CapabilityOverrideSchema).optional(),
31
32
  })
32
33
  .optional();
34
+ const WorktreeBranchPrefixSchema = z
35
+ .string()
36
+ .trim()
37
+ .min(1)
38
+ .refine(isValidWorktreeBranchPrefix, "Invalid git branch prefix")
39
+ .transform((value) => normalizeWorktreeBranchPrefix(value));
33
40
  export const SettingsSchema = z
34
41
  .object({
35
42
  agent: z
@@ -214,6 +221,11 @@ export const SettingsSchema = z
214
221
  maxHistory: z.number().default(100),
215
222
  })
216
223
  .default({}),
224
+ worktree: z
225
+ .object({
226
+ branchPrefix: WorktreeBranchPrefixSchema.default(DEFAULT_WORKTREE_BRANCH_PREFIX),
227
+ })
228
+ .default({}),
217
229
  // The record key IS the server name at runtime (MCPManager.connectAll
218
230
  // uses Object.entries keys; desktop's persist strips the `name` field and
219
231
  // keys by it via stripNameFromServer). So `name` here is optional and
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Skill scanner — discovers <base>/<name>/SKILL.md files from project + user
3
- * directories AND from installed plugins. Mirrors Claude Code's
2
+ * Skill scanner — discovers <base>/<name>/SKILL.md files from project
3
+ * `.code-shell/skills`, project `.agents/skills`, user `.code-shell/skills`,
4
+ * and installed plugins. Mirrors Claude Code's
4
5
  * `loadSkillsFromSkillsDir` (skills/loadSkillsDir.ts:407) plus plugin
5
6
  * integration (utils/plugins/pluginLoader.ts).
6
7
  */