@cjhyy/code-shell-core 0.6.0-rc.11 → 0.6.0-rc.13

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 (65) hide show
  1. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  2. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  3. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  4. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  5. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  6. package/dist/cc-orchestrator/external-agent-session-store.js +144 -0
  7. package/dist/context/manager.d.ts +3 -1
  8. package/dist/context/manager.js +24 -15
  9. package/dist/credentials/types.d.ts +2 -2
  10. package/dist/engine/engine.d.ts +5 -1
  11. package/dist/engine/engine.js +92 -51
  12. package/dist/engine/turn-loop.js +1 -1
  13. package/dist/git/worktree.d.ts +49 -6
  14. package/dist/git/worktree.js +265 -31
  15. package/dist/index.d.ts +4 -3
  16. package/dist/index.js +3 -2
  17. package/dist/logging/logger.js +6 -6
  18. package/dist/plugins/installer/installFromSource.js +9 -1
  19. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  20. package/dist/plugins/installer/sourcePath.js +50 -0
  21. package/dist/plugins/pluginInstaller.js +24 -22
  22. package/dist/protocol/chat-session-manager.d.ts +1 -0
  23. package/dist/protocol/chat-session-manager.js +13 -0
  24. package/dist/protocol/server.d.ts +8 -0
  25. package/dist/protocol/server.js +45 -8
  26. package/dist/protocol/types.d.ts +4 -0
  27. package/dist/protocol/types.js +4 -0
  28. package/dist/run/FileRunStore.js +10 -1
  29. package/dist/run/Heartbeat.js +12 -0
  30. package/dist/run/RunApprovalBackend.d.ts +3 -0
  31. package/dist/run/RunApprovalBackend.js +41 -6
  32. package/dist/run/RunLock.js +2 -0
  33. package/dist/run/RunManager.d.ts +2 -0
  34. package/dist/run/RunManager.js +64 -24
  35. package/dist/run/ids.d.ts +2 -0
  36. package/dist/run/ids.js +23 -0
  37. package/dist/session/session-manager.d.ts +35 -1
  38. package/dist/session/session-manager.js +189 -2
  39. package/dist/settings/manager.d.ts +1 -0
  40. package/dist/settings/manager.js +45 -26
  41. package/dist/settings/schema-export.d.ts +2 -3
  42. package/dist/settings/schema-export.js +2 -3
  43. package/dist/tool-system/builtin/background-jobs.d.ts +8 -1
  44. package/dist/tool-system/builtin/background-jobs.js +8 -1
  45. package/dist/tool-system/builtin/config.d.ts +2 -1
  46. package/dist/tool-system/builtin/config.js +16 -11
  47. package/dist/tool-system/builtin/drive-claude-code.d.ts +15 -2
  48. package/dist/tool-system/builtin/drive-claude-code.js +174 -39
  49. package/dist/tool-system/builtin/edit.js +5 -2
  50. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  51. package/dist/tool-system/builtin/generate-video.js +13 -4
  52. package/dist/tool-system/builtin/index.js +5 -1
  53. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  54. package/dist/tool-system/builtin/lsp.js +6 -3
  55. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  56. package/dist/tool-system/builtin/read.js +5 -2
  57. package/dist/tool-system/builtin/worktree.d.ts +2 -4
  58. package/dist/tool-system/builtin/worktree.js +250 -75
  59. package/dist/tool-system/builtin/write.js +5 -3
  60. package/dist/tool-system/context.d.ts +12 -0
  61. package/dist/tool-system/mcp-manager.d.ts +8 -0
  62. package/dist/tool-system/mcp-manager.js +32 -11
  63. package/dist/types.d.ts +12 -0
  64. package/dist/utils/toolDisplay.js +1 -1
  65. package/package.json +1 -1
@@ -30,6 +30,8 @@ export function selectPlatformScript(scripts, platform = process.platform) {
30
30
  * Validate worktree slug to prevent path traversal attacks.
31
31
  */
32
32
  export function validateWorktreeSlug(slug) {
33
+ if (slug.trim().length === 0)
34
+ throw new Error("Worktree slug cannot be empty");
33
35
  if (slug.length > 64)
34
36
  throw new Error("Worktree slug too long (max 64 chars)");
35
37
  if (/[^a-zA-Z0-9._-]/.test(slug))
@@ -41,7 +43,72 @@ export function validateWorktreeSlug(slug) {
41
43
  * Find the canonical git root (resolves worktree → main repo).
42
44
  */
43
45
  export function findGitRoot(cwd) {
44
- return execFileSync(GIT_BIN, ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", timeout: 5000 }).trim();
46
+ return findMainWorktreeRoot(cwd);
47
+ }
48
+ export function findMainWorktreeRoot(cwd) {
49
+ const commonDir = execFileSync(GIT_BIN, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
50
+ cwd,
51
+ encoding: "utf-8",
52
+ timeout: 5000,
53
+ }).trim();
54
+ return dirname(commonDir);
55
+ }
56
+ export function findWorktreeForBranch(cwd, branch) {
57
+ const normalized = normalizeBranchName(branch);
58
+ return listWorktrees(cwd).find((entry) => entry.branch === normalized)?.path;
59
+ }
60
+ export function assertBranchNotCheckedOut(cwd, branch) {
61
+ const existingPath = findWorktreeForBranch(cwd, branch);
62
+ if (existingPath) {
63
+ throw new Error(`branch ${normalizeBranchName(branch)} already checked out at ${existingPath}`);
64
+ }
65
+ }
66
+ export function branchExists(cwd, branch) {
67
+ try {
68
+ return (execFileSync(GIT_BIN, ["branch", "--list", normalizeBranchName(branch)], {
69
+ cwd,
70
+ encoding: "utf-8",
71
+ timeout: 10000,
72
+ }).trim().length > 0);
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ export function isGitWorktreeRoot(cwd) {
79
+ if (!existsSync(join(cwd, ".git")))
80
+ return false;
81
+ try {
82
+ const inside = execFileSync(GIT_BIN, ["rev-parse", "--is-inside-work-tree"], {
83
+ cwd,
84
+ encoding: "utf-8",
85
+ timeout: 5000,
86
+ }).trim();
87
+ if (inside !== "true")
88
+ return false;
89
+ const topLevel = execFileSync(GIT_BIN, ["rev-parse", "--path-format=absolute", "--show-toplevel"], {
90
+ cwd,
91
+ encoding: "utf-8",
92
+ timeout: 5000,
93
+ }).trim();
94
+ return resolve(topLevel) === resolve(cwd);
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ export function currentBranch(cwd) {
101
+ try {
102
+ const branch = execFileSync(GIT_BIN, ["branch", "--show-current"], {
103
+ cwd,
104
+ encoding: "utf-8",
105
+ timeout: 5000,
106
+ }).trim();
107
+ return branch || undefined;
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
45
112
  }
46
113
  /**
47
114
  * Create an isolated git worktree for an agent session.
@@ -51,6 +118,7 @@ export function createWorktree(cwd, slug, sessionId) {
51
118
  const gitRoot = findGitRoot(cwd);
52
119
  const branchName = `worktree/${slug}-${sessionId.slice(0, 8)}`;
53
120
  const worktreePath = resolve(gitRoot, "..", `.worktrees/${slug}-${sessionId.slice(0, 8)}`);
121
+ assertBranchNotCheckedOut(gitRoot, branchName);
54
122
  // Get current branch for later reference
55
123
  let originalBranch;
56
124
  try {
@@ -134,50 +202,87 @@ export async function runWorktreeSetup(worktreePath, script, opts = {}) {
134
202
  * Remove a worktree and optionally its branch.
135
203
  */
136
204
  export function removeWorktree(worktreePath, removeBranch = false) {
205
+ // The MAIN repo root, not the worktree's own toplevel. `git rev-parse
206
+ // --show-toplevel` from inside a worktree returns the worktree path, which
207
+ // is about to be deleted; the branch-delete must run from the main repo,
208
+ // which outlives the worktree. Derive it from the common git dir.
209
+ let mainRoot;
137
210
  try {
138
- // The MAIN repo root, not the worktree's own toplevel. `git rev-parse
139
- // --show-toplevel` from inside a worktree returns the worktree path, which
140
- // is about to be deleted; the branch-delete must run from the main repo,
141
- // which outlives the worktree. Derive it from the common git dir.
142
211
  const commonDir = execFileSync(GIT_BIN, ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: worktreePath, encoding: "utf-8", timeout: 5000 }).trim();
143
- const mainRoot = dirname(commonDir); // <main>/.git → <main>
144
- // Capture the worktree's branch BEFORE removing the worktree — afterwards
145
- // the directory is gone and `git branch --show-current` in it would fail
146
- // (silently, leaving removeBranch a no-op).
147
- let branch = "";
148
- if (removeBranch) {
149
- try {
150
- branch = execFileSync(GIT_BIN, ["branch", "--show-current"], {
151
- cwd: worktreePath,
152
- encoding: "utf-8",
153
- timeout: 5000,
154
- }).trim();
155
- }
156
- catch {
157
- // Detached HEAD or unreadable — nothing to delete.
158
- }
212
+ mainRoot = dirname(commonDir); // <main>/.git → <main>
213
+ }
214
+ catch (err) {
215
+ throw new Error(`failed to inspect worktree ${worktreePath}: ${gitErrorMessage(err)}`);
216
+ }
217
+ // Capture the worktree's branch BEFORE removing the worktree — afterwards
218
+ // the directory is gone and `git branch --show-current` in it would fail.
219
+ let branch = "";
220
+ if (removeBranch) {
221
+ try {
222
+ branch = execFileSync(GIT_BIN, ["branch", "--show-current"], {
223
+ cwd: worktreePath,
224
+ encoding: "utf-8",
225
+ timeout: 5000,
226
+ }).trim();
227
+ }
228
+ catch (err) {
229
+ throw new Error(`failed to determine branch for worktree ${worktreePath}: ${gitErrorMessage(err)}`);
230
+ }
231
+ if (!branch) {
232
+ throw new Error(`failed to determine branch for worktree ${worktreePath}`);
233
+ }
234
+ if (!branch.startsWith("worktree/")) {
235
+ throw new Error(`refusing to delete non-CodeShell worktree branch ${branch}`);
159
236
  }
237
+ }
238
+ try {
160
239
  execFileSync(GIT_BIN, ["worktree", "remove", worktreePath, "--force"], {
161
240
  cwd: mainRoot,
162
241
  timeout: 30000,
163
242
  });
164
- if (removeBranch && branch.startsWith("worktree/")) {
165
- try {
166
- execFileSync(GIT_BIN, ["branch", "-D", branch], { cwd: mainRoot, timeout: 10000 });
167
- }
168
- catch {
169
- // Branch might already be removed.
170
- }
243
+ }
244
+ catch (err) {
245
+ throw new Error(`failed to remove worktree ${worktreePath}: ${gitErrorMessage(err)}`);
246
+ }
247
+ if (removeBranch) {
248
+ try {
249
+ execFileSync(GIT_BIN, ["branch", "-D", branch], { cwd: mainRoot, timeout: 10000 });
250
+ }
251
+ catch (err) {
252
+ return {
253
+ dirRemoved: true,
254
+ branch,
255
+ branchDeleted: false,
256
+ branchError: gitErrorMessage(err),
257
+ };
171
258
  }
172
259
  }
260
+ return removeBranch ? { dirRemoved: true, branch, branchDeleted: true } : { dirRemoved: true };
261
+ }
262
+ export function worktreeHasUncommittedChanges(worktreePath) {
263
+ try {
264
+ return (execFileSync(GIT_BIN, ["status", "--porcelain"], {
265
+ cwd: worktreePath,
266
+ encoding: "utf-8",
267
+ timeout: 10000,
268
+ }).trim().length > 0);
269
+ }
173
270
  catch {
174
- // Worktree might already be removed
271
+ return false;
175
272
  }
176
273
  }
274
+ export function worktreeHasUncommittedOrAheadChanges(worktreePath, baseRef) {
275
+ if (worktreeHasUncommittedChanges(worktreePath))
276
+ return true;
277
+ const comparisonBase = baseRef && commitRefExists(worktreePath, baseRef)
278
+ ? baseRef
279
+ : findComparisonBaseRef(worktreePath);
280
+ return comparisonBase ? aheadCommitCount(worktreePath, comparisonBase) > 0 : false;
281
+ }
177
282
  /**
178
283
  * List active worktrees.
179
284
  */
180
- export function listWorktrees(cwd) {
285
+ export function listWorktrees(cwd, opts = {}) {
181
286
  const raw = execFileSync(GIT_BIN, ["worktree", "list", "--porcelain"], {
182
287
  cwd,
183
288
  encoding: "utf-8",
@@ -202,7 +307,136 @@ export function listWorktrees(cwd) {
202
307
  }
203
308
  if (current.path)
204
309
  entries.push(current);
205
- return entries;
310
+ if (!opts.includeDiffSummary && !opts.workspaceOwners?.length)
311
+ return entries;
312
+ let mainRoot = "";
313
+ try {
314
+ mainRoot = findMainWorktreeRoot(cwd);
315
+ }
316
+ catch {
317
+ mainRoot = entries[0]?.path ?? "";
318
+ }
319
+ const baseRef = opts.includeDiffSummary ? findComparisonBaseRef(mainRoot || cwd) : undefined;
320
+ return entries.map((entry) => {
321
+ const owners = ownersForWorktree(entry.path, opts.workspaceOwners ?? []);
322
+ return {
323
+ ...entry,
324
+ ...(mainRoot ? { isMain: resolve(entry.path) === resolve(mainRoot) } : {}),
325
+ ...(opts.includeDiffSummary ? { diff: diffSummary(entry.path, baseRef) } : {}),
326
+ ...(owners.length > 0
327
+ ? {
328
+ occupiedBySessionIds: owners,
329
+ occupiedByOtherSession: owners.some((id) => id !== opts.currentSessionId),
330
+ }
331
+ : {}),
332
+ };
333
+ });
334
+ }
335
+ function ownersForWorktree(path, owners) {
336
+ const target = resolve(path);
337
+ return owners
338
+ .filter((owner) => {
339
+ const workspace = owner.workspace;
340
+ if (!workspace)
341
+ return false;
342
+ return resolve(workspace.root) === target;
343
+ })
344
+ .map((owner) => owner.sessionId);
345
+ }
346
+ function findComparisonBaseRef(cwd) {
347
+ for (const ref of ["main", "master", "origin/main", "origin/master"]) {
348
+ if (commitRefExists(cwd, ref))
349
+ return ref;
350
+ }
351
+ return undefined;
352
+ }
353
+ function commitRefExists(cwd, ref) {
354
+ try {
355
+ execFileSync(GIT_BIN, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
356
+ cwd,
357
+ encoding: "utf-8",
358
+ timeout: 5000,
359
+ });
360
+ return true;
361
+ }
362
+ catch {
363
+ return false;
364
+ }
365
+ }
366
+ function diffSummary(cwd, baseRef) {
367
+ const dirtyFiles = statusFileSet(cwd);
368
+ const committedFiles = baseRef ? changedFilesSinceBase(cwd, baseRef) : new Set();
369
+ const changedFiles = new Set([...dirtyFiles, ...committedFiles]);
370
+ return {
371
+ ...(baseRef ? { baseRef } : {}),
372
+ changedFiles: changedFiles.size,
373
+ aheadCommits: baseRef ? aheadCommitCount(cwd, baseRef) : 0,
374
+ hasUncommittedChanges: dirtyFiles.size > 0,
375
+ };
376
+ }
377
+ function changedFilesSinceBase(cwd, baseRef) {
378
+ const out = gitOutput(cwd, ["diff", "--name-only", `${baseRef}...HEAD`]);
379
+ const raw = out ?? gitOutput(cwd, ["diff", "--name-only", `${baseRef}..HEAD`]) ?? "";
380
+ return new Set(raw
381
+ .split("\n")
382
+ .map((line) => line.trim())
383
+ .filter(Boolean));
384
+ }
385
+ function aheadCommitCount(cwd, baseRef) {
386
+ const out = gitOutput(cwd, ["rev-list", "--count", `${baseRef}..HEAD`]);
387
+ const n = Number.parseInt(out?.trim() ?? "0", 10);
388
+ return Number.isFinite(n) ? n : 0;
389
+ }
390
+ function statusFileSet(cwd) {
391
+ const raw = gitOutput(cwd, ["status", "--porcelain=v1"]) ?? "";
392
+ const files = new Set();
393
+ for (const line of raw.split("\n")) {
394
+ if (!line.trim())
395
+ continue;
396
+ const path = line.slice(3).trim();
397
+ if (!path)
398
+ continue;
399
+ const renameTarget = path.includes(" -> ") ? path.split(" -> ").at(-1) : path;
400
+ if (renameTarget)
401
+ files.add(unquoteGitPath(renameTarget));
402
+ }
403
+ return files;
404
+ }
405
+ function unquoteGitPath(path) {
406
+ if (!path.startsWith('"') || !path.endsWith('"'))
407
+ return path;
408
+ try {
409
+ return JSON.parse(path);
410
+ }
411
+ catch {
412
+ return path.slice(1, -1);
413
+ }
414
+ }
415
+ function gitOutput(cwd, args) {
416
+ try {
417
+ return execFileSync(GIT_BIN, args, {
418
+ cwd,
419
+ encoding: "utf-8",
420
+ timeout: 10000,
421
+ });
422
+ }
423
+ catch {
424
+ return undefined;
425
+ }
426
+ }
427
+ function gitErrorMessage(err) {
428
+ const stderr = err.stderr;
429
+ if (Buffer.isBuffer(stderr)) {
430
+ const msg = stderr.toString("utf-8").trim();
431
+ if (msg)
432
+ return msg;
433
+ }
434
+ if (typeof stderr === "string" && stderr.trim())
435
+ return stderr.trim();
436
+ return err instanceof Error ? err.message : String(err);
437
+ }
438
+ function normalizeBranchName(branch) {
439
+ return branch.replace(/^refs\/heads\//, "");
206
440
  }
207
441
  /**
208
442
  * Symlink large directories (node_modules, .venv, etc.) from main repo to worktree.
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.6.0-rc.8";
7
- export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
6
+ export declare const VERSION = "0.6.0-rc.13";
7
+ export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionWorkspace, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  export { Engine, loadAgentDefinitionsForCwd } from "./engine/engine.js";
10
10
  export type { EngineConfig, EngineHookConfig, EngineResult } from "./engine/types.js";
@@ -45,6 +45,7 @@ export { Methods, ErrorCodes, type RpcMessage, type RunResult, } from "./protoco
45
45
  export { Transcript } from "./session/transcript.js";
46
46
  export { SessionManager } from "./session/session-manager.js";
47
47
  export { FileHistory } from "./session/file-history.js";
48
+ export { createWorktree, currentBranch, findMainWorktreeRoot, listWorktrees, removeWorktree, validateWorktreeSlug, worktreeHasUncommittedOrAheadChanges, worktreeHasUncommittedChanges, type ListWorktreesOptions, type RemoveWorktreeResult, type WorktreeDiffSummary, type WorktreeInfo, type WorktreeWorkspaceOwner, } from "./git/worktree.js";
48
49
  export { latestUndoTarget, earliestSnapshotsPerFile, latestTurnUndoTargets, latestRedoTargets, } from "./session/undo-target.js";
49
50
  export { diffLines, renderDiffPreview, type DiffLine } from "./session/simple-diff.js";
50
51
  export { MemoryManager } from "./session/memory.js";
@@ -103,7 +104,7 @@ export type { IterateConfig, IterateResult, IterateSubject, IterateFormat, Itera
103
104
  export { RunManager, type RunManagerConfig, type RunStore, FileRunStore, RunQueue, EngineRunner, AUTOMATION_PROMPT_NOTE, AUTOMATION_RUN_SOURCE, type EngineRunnerConfig, type RunExecutionHandle, type RunExecutor, type CustomToolEntry, RunApprovalBackend, createRunAskUserFn, type RunLifecycleHooks, CheckpointWriter, ArtifactTracker, RunLock, type RunLockConfig, type RunLockAcquireResult, Heartbeat, NoopEvaluator, CompositeEvaluator, type Evaluator, type EvaluatorResult, type EvaluatorContext, type RunStatus, type RunSnapshot, type RunEvent, type RunCheckpoint, type RunApproval, type RunArtifactRef, type SubmitRunInput, type ResumeRunInput, type ListRunsQuery, type RunStreamEvent, type RunStreamCallback, type DetachFn, VALID_TRANSITIONS, createRunManager, type CreateRunManagerOptions, } from "./run/index.js";
104
105
  export { defineProduct, type ProductDefinition, type ProductPreset, type ProductAdapter, type ProductContract, type CustomTool, type ProductRuntimeOptions, type ProductInstance, } from "./product/index.js";
105
106
  export { logger } from "./logging/logger.js";
106
- export { SettingsManager, type SettingsScope } from "./settings/manager.js";
107
+ export { SettingsManager, userHome, type SettingsScope } from "./settings/manager.js";
107
108
  export { migrateConfig, configVersionOf, CURRENT_CONFIG_VERSION, type MigrationStep, } from "./settings/migrate-config.js";
108
109
  export { SettingsSchema, validateSettings } from "./settings/schema.js";
109
110
  export { settingsJsonSchema, writeSettingsSchemaFile } from "./settings/schema-export.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.6.0-rc.8";
6
+ export const VERSION = "0.6.0-rc.13";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Engine (primary API) ────────────────────────────────────────
@@ -44,6 +44,7 @@ export { Methods, ErrorCodes, } from "./protocol/types.js";
44
44
  export { Transcript } from "./session/transcript.js";
45
45
  export { SessionManager } from "./session/session-manager.js";
46
46
  export { FileHistory } from "./session/file-history.js";
47
+ export { createWorktree, currentBranch, findMainWorktreeRoot, listWorktrees, removeWorktree, validateWorktreeSlug, worktreeHasUncommittedOrAheadChanges, worktreeHasUncommittedChanges, } from "./git/worktree.js";
47
48
  export { latestUndoTarget, earliestSnapshotsPerFile, latestTurnUndoTargets, latestRedoTargets, } from "./session/undo-target.js";
48
49
  export { diffLines, renderDiffPreview } from "./session/simple-diff.js";
49
50
  export { MemoryManager } from "./session/memory.js";
@@ -121,7 +122,7 @@ export { defineProduct, } from "./product/index.js";
121
122
  // ─── Logging ─────────────────────────────────────────────────────
122
123
  export { logger } from "./logging/logger.js";
123
124
  // ─── Settings ────────────────────────────────────────────────────
124
- export { SettingsManager } from "./settings/manager.js";
125
+ export { SettingsManager, userHome } from "./settings/manager.js";
125
126
  export { migrateConfig, configVersionOf, CURRENT_CONFIG_VERSION, } from "./settings/migrate-config.js";
126
127
  export { SettingsSchema, validateSettings } from "./settings/schema.js";
127
128
  export { settingsJsonSchema, writeSettingsSchemaFile } from "./settings/schema-export.js";
@@ -121,12 +121,12 @@ function resolveCategoryFilter() {
121
121
  //
122
122
  // Two mechanisms, layered:
123
123
  //
124
- // 1. `runWithSid(sid, fn)` — preferred. Engine.run wraps its entire body
125
- // in this AsyncLocalStorage scope, so every log line emitted inside the
126
- // (possibly deeply async) call tree picks up the right sid. Concurrent
127
- // parent + child Engines coexist because each `await` boundary preserves
128
- // the ALS context they don't trample each other like a single module
129
- // global would.
124
+ // 1. `runWithSid(sid, fn)` — preferred. Once Engine.run has resolved the
125
+ // authoritative sid, its session-bound execution body runs in this
126
+ // AsyncLocalStorage scope, so every log line emitted inside the (possibly
127
+ // deeply async) call tree picks up the right sid. Concurrent parent + child
128
+ // Engines coexist because each `await` boundary preserves the ALS context
129
+ // they don't trample each other like a single module global would.
130
130
  //
131
131
  // 2. `_currentSidFallback` — module-level mutable, written by `setCurrentSid`.
132
132
  // Used only as a fallback when a code path runs outside any ALS scope
@@ -5,6 +5,7 @@ import { gitClone, gitRevParseHead } from "../gitOps.js";
5
5
  import { installPluginFromPath } from "./install.js";
6
6
  import { pluginMetaPath } from "./paths.js";
7
7
  import { PluginInstallError } from "./types.js";
8
+ import { resolveContainedPluginSubpath } from "./sourcePath.js";
8
9
  /**
9
10
  * Remote install orchestrator: a thin bridge over the existing pieces. Clone
10
11
  * the git source to a private temp dir, hand the (sub)directory to the local
@@ -26,7 +27,14 @@ export async function installPluginFromSource(parsed, name, installedAt) {
26
27
  if (!clone.ok) {
27
28
  throw new PluginInstallError(`clone failed: ${clone.error}`);
28
29
  }
29
- const realSrc = parsed.subdir ? join(tmp, parsed.subdir) : tmp;
30
+ const realSrc = parsed.subdir
31
+ ? (() => {
32
+ const resolved = resolveContainedPluginSubpath(tmp, parsed.subdir, "subdir");
33
+ if (!resolved.ok)
34
+ throw new PluginInstallError(resolved.error);
35
+ return resolved.path;
36
+ })()
37
+ : tmp;
30
38
  if (!existsSync(realSrc) || !statSync(realSrc).isDirectory()) {
31
39
  throw new PluginInstallError(`subdir not found in repo: ${parsed.subdir}`);
32
40
  }
@@ -0,0 +1,9 @@
1
+ export type ContainedPluginSubpathResult = {
2
+ ok: true;
3
+ path: string;
4
+ } | {
5
+ ok: false;
6
+ error: string;
7
+ };
8
+ export declare function validateRelativePluginSubpath(subpath: string, label: string): string | null;
9
+ export declare function resolveContainedPluginSubpath(root: string, subpath: string, label: string): ContainedPluginSubpathResult;
@@ -0,0 +1,50 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute, resolve, sep } from "node:path";
3
+ function normPath(path) {
4
+ return process.platform === "win32" ? path.toLowerCase() : path;
5
+ }
6
+ function isContainedOrRoot(child, parent) {
7
+ const c = normPath(child);
8
+ const parentPath = normPath(parent);
9
+ const parentPrefix = normPath(parent.endsWith(sep) ? parent : parent + sep);
10
+ return c === parentPath || c.startsWith(parentPrefix);
11
+ }
12
+ export function validateRelativePluginSubpath(subpath, label) {
13
+ if (typeof subpath !== "string" || subpath.length === 0) {
14
+ return `${label} must be a non-empty relative path`;
15
+ }
16
+ if (subpath.includes("\0")) {
17
+ return `${label} must not contain NUL bytes: ${subpath}`;
18
+ }
19
+ if (isAbsolute(subpath)) {
20
+ return `${label} must be relative and inside the source tree: ${subpath}`;
21
+ }
22
+ if (subpath.split(/[\\/]+/).includes("..")) {
23
+ return `${label} must not contain parent-directory segments: ${subpath}`;
24
+ }
25
+ return null;
26
+ }
27
+ export function resolveContainedPluginSubpath(root, subpath, label) {
28
+ const invalid = validateRelativePluginSubpath(subpath, label);
29
+ if (invalid)
30
+ return { ok: false, error: invalid };
31
+ let realRoot;
32
+ try {
33
+ realRoot = realpathSync(root);
34
+ }
35
+ catch (err) {
36
+ return { ok: false, error: `${label} root could not be resolved: ${err.message}` };
37
+ }
38
+ const candidate = resolve(root, subpath);
39
+ let realCandidate;
40
+ try {
41
+ realCandidate = realpathSync(candidate);
42
+ }
43
+ catch {
44
+ return { ok: false, error: `${label} not found in source tree: ${subpath}` };
45
+ }
46
+ if (!isContainedOrRoot(realCandidate, realRoot)) {
47
+ return { ok: false, error: `${label} escapes the source tree: ${subpath}` };
48
+ }
49
+ return { ok: true, path: realCandidate };
50
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync, realpathSync, rmSync, rmdirSync } from "node:fs";
8
8
  import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
9
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
9
+ import { dirname, join, sep } from "node:path";
10
10
  import { homedir, tmpdir } from "node:os";
11
11
  import { gitClone, gitRevParseHead, gitSparseCheckoutAdd, githubRepoToCloneUrl, } from "./gitOps.js";
12
12
  import { loadMarketplace } from "./marketplaceManager.js";
@@ -15,6 +15,7 @@ import { appendInstallEntry, pluginInstallKey, readInstalledPlugins, removeInsta
15
15
  import { rewritePluginVars } from "./varRewrite.js";
16
16
  import { assertSafePluginName } from "./installer/paths.js";
17
17
  import { pruneDisabledSettingsForPlugin } from "./installer/pruneDisabled.js";
18
+ import { resolveContainedPluginSubpath, validateRelativePluginSubpath, } from "./installer/sourcePath.js";
18
19
  function userHome() {
19
20
  return process.env.HOME ?? homedir();
20
21
  }
@@ -81,12 +82,11 @@ export function resolveSafePluginPath(installPath, cacheRoot) {
81
82
  return resolvedTarget;
82
83
  }
83
84
  async function materializePath(marketplaceInstallLocation, relativePath, cacheTarget) {
84
- const src = isAbsolute(relativePath)
85
- ? relativePath
86
- : resolve(marketplaceInstallLocation, relativePath);
87
- if (!existsSync(src)) {
88
- return { ok: false, error: `plugin source path "${relativePath}" not found in marketplace` };
85
+ const contained = resolveContainedPluginSubpath(marketplaceInstallLocation, relativePath, "plugin source path");
86
+ if (!contained.ok) {
87
+ return { ok: false, error: contained.error };
89
88
  }
89
+ const src = contained.path;
90
90
  await mkdir(dirname(cacheTarget), { recursive: true });
91
91
  if (existsSync(cacheTarget))
92
92
  await rm(cacheTarget, { recursive: true, force: true });
@@ -106,22 +106,23 @@ async function materializeGit(url, ref, cacheTarget) {
106
106
  return { ok: true, sha: head.stdout };
107
107
  }
108
108
  async function materializeGitSubdir(url, subPath, ref, cacheTarget) {
109
+ const invalid = validateRelativePluginSubpath(subPath, "git-subdir path");
110
+ if (invalid)
111
+ return { ok: false, error: invalid };
109
112
  // Clone to a tempdir, then copy the subdir over.
110
113
  const tmp = await mkdtemp(join(tmpdir(), "plugin-clone-"));
111
114
  try {
112
- const clone = await gitClone(url, tmp + "/repo", { full: true, ...(ref ? { ref } : {}) });
115
+ const repoDir = join(tmp, "repo");
116
+ const clone = await gitClone(url, repoDir, { full: true, ...(ref ? { ref } : {}) });
113
117
  if (!clone.ok)
114
118
  return { ok: false, error: clone.error };
115
- const head = await gitRevParseHead(tmp + "/repo");
119
+ const head = await gitRevParseHead(repoDir);
116
120
  if (!head.ok)
117
121
  return { ok: false, error: head.error };
118
- const src = join(tmp, "repo", subPath);
119
- if (!existsSync(src)) {
120
- return {
121
- ok: false,
122
- error: `git-subdir path "${subPath}" not found in cloned repository`,
123
- };
124
- }
122
+ const contained = resolveContainedPluginSubpath(repoDir, subPath, "git-subdir path");
123
+ if (!contained.ok)
124
+ return { ok: false, error: contained.error };
125
+ const src = contained.path;
125
126
  await mkdir(dirname(cacheTarget), { recursive: true });
126
127
  if (existsSync(cacheTarget))
127
128
  await rm(cacheTarget, { recursive: true, force: true });
@@ -162,19 +163,20 @@ async function materialize(source, marketplaceInstallLocation, marketplace, plug
162
163
  // once we know it. For path sources there's no SHA — use "local".
163
164
  const placeholder = pluginCacheDir(marketplace, plugin, "_pending_");
164
165
  if (typeof source === "string") {
166
+ const invalid = validateRelativePluginSubpath(source, "plugin source path");
167
+ if (invalid)
168
+ return { ok: false, error: invalid };
165
169
  // The marketplace was cloned sparse (only the manifest dirs are on disk),
166
170
  // so a local-path plugin's tree may not be checked out yet. Expand the
167
171
  // sparse-checkout to include it before reading. Best-effort: on a
168
172
  // non-sparse (full) clone this errors harmlessly and the files are already
169
173
  // present, so we ignore the result and let materializePath report a real
170
174
  // missing-path error if the subdir truly isn't there.
171
- if (!isAbsolute(source)) {
172
- // Strip a leading "./" in non-cone sparse mode the literal "./" prefix
173
- // doesn't match repo paths (which are stored without it), so the
174
- // expand would silently no-op and the tree would stay un-materialized.
175
- const sparseRel = source.replace(/^\.\//, "");
176
- await gitSparseCheckoutAdd(marketplaceInstallLocation, sparseRel);
177
- }
175
+ // Strip a leading "./" — in non-cone sparse mode the literal "./" prefix
176
+ // doesn't match repo paths (which are stored without it), so the
177
+ // expand would silently no-op and the tree would stay un-materialized.
178
+ const sparseRel = source.replace(/^\.\//, "");
179
+ await gitSparseCheckoutAdd(marketplaceInstallLocation, sparseRel);
178
180
  const r = await materializePath(marketplaceInstallLocation, source, placeholder);
179
181
  if (!r.ok)
180
182
  return r;
@@ -44,4 +44,5 @@ export declare class ChatSessionManager {
44
44
  sweepIdle(): void;
45
45
  startIdleSweeper(intervalMs?: number): void;
46
46
  stopIdleSweeper(): void;
47
+ private unregisterMcpOwner;
47
48
  }
@@ -1,6 +1,7 @@
1
1
  import { ChatSession } from "./chat-session.js";
2
2
  import { backgroundShellManager } from "../runtime/background-shell.js";
3
3
  import { clearAgentOutputFiles } from "../tool-system/builtin/agent-output-file.js";
4
+ import { logger } from "../logging/logger.js";
4
5
  export class ChatSessionManager {
5
6
  sessions = new Map();
6
7
  runtime;
@@ -59,6 +60,7 @@ export class ChatSessionManager {
59
60
  if (!s)
60
61
  return;
61
62
  s.cancel();
63
+ this.unregisterMcpOwner(s);
62
64
  this.sessions.delete(sessionId);
63
65
  }
64
66
  closeAll() {
@@ -110,4 +112,15 @@ export class ChatSessionManager {
110
112
  clearInterval(this.sweeper);
111
113
  this.sweeper = null;
112
114
  }
115
+ unregisterMcpOwner(session) {
116
+ const mcpPool = this.runtime.mcpPool;
117
+ if (typeof mcpPool?.unregisterOwner !== "function")
118
+ return;
119
+ void mcpPool.unregisterOwner(session.engine).catch((err) => {
120
+ logger.warn("chat_session.mcp_owner_unregister_failed", {
121
+ sessionId: session.id,
122
+ error: err instanceof Error ? err.message : String(err),
123
+ });
124
+ });
125
+ }
113
126
  }
@@ -84,6 +84,14 @@ export declare class AgentServer {
84
84
  private approvalTimers;
85
85
  /** Default approval timeout: 5 minutes */
86
86
  private static readonly APPROVAL_TIMEOUT_MS;
87
+ /**
88
+ * Wall-clock timeout for AskUserQuestion during a GOAL run (10 minutes).
89
+ * Plain interactive asks stay untimed; only a self-driving goal run arms this
90
+ * so a mid-goal question can't suspend the loop forever with nobody watching.
91
+ * Longer than the tool-approval timeout — a user who IS present deserves more
92
+ * time to compose a real answer before we assume-and-continue.
93
+ */
94
+ private static readonly GOAL_ASKUSER_TIMEOUT_MS;
87
95
  /**
88
96
  * Unsubscribe handle for the process-local `agentNotificationBus`
89
97
  * subscription set up in the constructor. Called from `close()` so a