@cjhyy/code-shell-capability-coding 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.
@@ -2,6 +2,8 @@ import { Hunk } from "./types.js";
2
2
  export interface ApplyPatchOptions {
3
3
  /** Working directory for resolving relative paths in the patch. */
4
4
  cwd: string;
5
+ /** Immutable roots authorized for this run. Relative patch paths still resolve from cwd. */
6
+ workspaceRoots?: readonly string[];
5
7
  /**
6
8
  * If true, skip the snapshot/rollback safety net and apply each planned
7
9
  * change one at a time, leaving partial work on disk if a later write
@@ -27,10 +27,10 @@ export async function applyPatch(hunks, options) {
27
27
  if (hunks.length === 0) {
28
28
  throw new Error("No files were modified.");
29
29
  }
30
- const planned = await planHunks(hunks, options.cwd);
30
+ const planned = await planHunks(hunks, options.cwd, options.workspaceRoots ?? [options.cwd]);
31
31
  return commitPlanned(planned, !!options.allowPartialOnCommit);
32
32
  }
33
- async function planHunks(hunks, cwd) {
33
+ async function planHunks(hunks, cwd, workspaceRoots) {
34
34
  const set = {
35
35
  byPath: new Map(),
36
36
  added: [],
@@ -45,7 +45,7 @@ async function planHunks(hunks, cwd) {
45
45
  }
46
46
  for (const hunk of hunks) {
47
47
  const original = hunk.path;
48
- const sourcePath = resolveAgainst(original, cwd);
48
+ const sourcePath = resolveAgainst(original, cwd, workspaceRoots);
49
49
  if (hunk.kind === "add") {
50
50
  const existed = await readIfExists(sourcePath);
51
51
  schedule(sourcePath, {
@@ -87,7 +87,7 @@ async function planHunks(hunks, cwd) {
87
87
  const originalText = toLf(originalRaw);
88
88
  const newText = applyEol(applyChunksToText(originalText, hunk.chunks, sourcePath), eol);
89
89
  if (hunk.movePath !== undefined) {
90
- const destPath = resolveAgainst(hunk.movePath, cwd);
90
+ const destPath = resolveAgainst(hunk.movePath, cwd, workspaceRoots);
91
91
  if (destPath === sourcePath) {
92
92
  // Rename to self → degenerate update.
93
93
  schedule(sourcePath, {
@@ -248,12 +248,12 @@ async function writeChange(change) {
248
248
  * symlink planted inside cwd that points outside — a plain string `resolve()`
249
249
  * would look contained while the write follows the link out.
250
250
  */
251
- function resolveAgainst(p, cwd) {
251
+ function resolveAgainst(p, cwd, workspaceRoots) {
252
252
  const resolved = isAbsolute(p) ? p : resolve(cwd, p);
253
- const realCwd = nearestRealPath(cwd);
254
253
  const realTarget = nearestRealPath(resolved);
255
- if (!isInside(realTarget, realCwd)) {
256
- throw new Error(`Refusing to apply patch outside the working directory: "${p}" resolves to "${realTarget}", which escapes "${realCwd}".`);
254
+ const realRoots = workspaceRoots.map(nearestRealPath);
255
+ if (!realRoots.some((root) => isInside(realTarget, root))) {
256
+ throw new Error(`Refusing to apply patch outside the workspace roots: "${p}" resolves to "${realTarget}".`);
257
257
  }
258
258
  return resolved;
259
259
  }
@@ -72,7 +72,10 @@ export async function applyPatchTool(args, ctx) {
72
72
  const cwd = ctx?.cwd ?? process.cwd();
73
73
  let result;
74
74
  try {
75
- result = await applyPatch(parsed.hunks, { cwd });
75
+ result = await applyPatch(parsed.hunks, {
76
+ cwd,
77
+ workspaceRoots: ctx?.workspace?.roots.map((root) => root.path),
78
+ });
76
79
  }
77
80
  catch (err) {
78
81
  return `Error applying patch: ${err.message}`;
@@ -275,6 +275,23 @@ function safeFinalizeDriveWorktree(managed) {
275
275
  };
276
276
  }
277
277
  }
278
+ /**
279
+ * Finalize a managed worktree once `run` settles, never before.
280
+ *
281
+ * Aborting a run only starts the driver's async process-tree teardown, so the
282
+ * external CLI can still be holding the worktree (and git's index.lock) when
283
+ * abort() returns. Cleaning up synchronously races that; waiting for the run to
284
+ * settle does not. Doubles as the rejection sink for an aborted run.
285
+ */
286
+ function finalizeDriveWorktreeAfter(run, managed) {
287
+ void run
288
+ .catch(() => undefined)
289
+ .then(() => {
290
+ // safeFinalizeDriveWorktree already degrades a failed cleanup to a
291
+ // "kept" note rather than throwing, so nothing can escape this chain.
292
+ safeFinalizeDriveWorktree(managed);
293
+ });
294
+ }
278
295
  function summarizePrompt(prompt, max = 120) {
279
296
  const oneLine = prompt.replace(/\s+/g, " ").trim();
280
297
  if (oneLine.length <= max)
@@ -607,6 +624,16 @@ function attachDriveCompletion(params) {
607
624
  });
608
625
  }
609
626
  function trackBackgroundRun(params) {
627
+ const { sessionId } = params;
628
+ if (!isValidSessionId(sessionId)) {
629
+ // Mirrors the background:true refusal above: a job whose completion
630
+ // notification cannot be routed would run and then vanish. Say so instead
631
+ // of registering work nobody can track. The caller aborts the run and
632
+ // finalizes any managed worktree on this error path.
633
+ return {
634
+ error: "Error: cannot track this DriveAgent run without a session — its result notification would be dropped. Re-run inside a session, or keep the task short enough to finish in the foreground.",
635
+ };
636
+ }
610
637
  const conflict = duplicateCwdError(params.effectiveWorkspaceCwd, params.effectiveWorkspaceRoot, params.writable);
611
638
  if (conflict)
612
639
  return { error: conflict };
@@ -616,7 +643,7 @@ function trackBackgroundRun(params) {
616
643
  // Publish ownership before starting the external process. Registry
617
644
  // listeners are synchronous and may re-enter session teardown from the
618
645
  // start notification; starting first would leave that process orphaned.
619
- backgroundJobRegistry.start(jobId, params.sessionId, params.label, {
646
+ backgroundJobRegistry.start(jobId, sessionId, params.label, {
620
647
  kind: "drive-agent",
621
648
  launchCwd: params.cwd,
622
649
  effectiveWorkspaceCwd: params.effectiveWorkspaceCwd,
@@ -660,7 +687,7 @@ function trackBackgroundRun(params) {
660
687
  backgroundJobRegistry.finish(jobId, { status: "failed", finalText: message });
661
688
  return { error: `Error: failed to start DriveAgent job: ${message}` };
662
689
  }
663
- attachDriveCompletion({ ...params, jobId, run });
690
+ attachDriveCompletion({ ...params, sessionId, jobId, run });
664
691
  return { jobId };
665
692
  }
666
693
  async function waitForForegroundOrHandoff(run, handoffMs) {
@@ -963,13 +990,26 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
963
990
  lease.release();
964
991
  return appendLifecycleNote(`${cliName} 运行出错:${error instanceof Error ? error.message : String(error)}`, lifecycle);
965
992
  }
966
- if (result.kind === "handoff" && isValidSessionId(ctx?.sessionId)) {
993
+ if (result.kind === "handoff") {
994
+ // Handing off is NOT gated on a valid ctx.sessionId. Gating it there left
995
+ // a session-less foreground run falling through to `await run`, blocking
996
+ // until the 30min tool cap whose abort kills the external CLI — the work
997
+ // was destroyed with no jobId and no notification path. trackBackgroundRun
998
+ // owns the session check instead: it registers a tracked job when the
999
+ // session can receive the completion, and otherwise returns an error the
1000
+ // branch below turns into an abort + deferred cleanup + explanation.
1001
+ //
967
1002
  // Registration below is synchronous. Release the foreground lease and
968
1003
  // replace it with the background registry entry in the same event-loop
969
- // turn, so another dispatch cannot slip into a gap.
1004
+ // turn, so another dispatch cannot slip into a gap. The same invariant
1005
+ // covers the failure path: the lease is dropped and no job exists between
1006
+ // here and the return below, so that span MUST stay synchronous — an
1007
+ // await would open the gap to a concurrent writable dispatch. Worktree
1008
+ // finalization is deliberately NOT in that span: it is deferred to run
1009
+ // settlement, after this turn has already returned.
970
1010
  lease.release();
971
1011
  const tracked = trackBackgroundRun({
972
- sessionId: ctx.sessionId,
1012
+ sessionId: ctx?.sessionId,
973
1013
  label,
974
1014
  cli,
975
1015
  cwd,
@@ -993,9 +1033,16 @@ export function makeDriveAgentTool(runner = defaultRunner, fixedCli, options = {
993
1033
  originClientMessageId: ctx?.originClientMessageId,
994
1034
  });
995
1035
  if ("error" in tracked) {
1036
+ // abort() only STARTS teardown (SIGTERM, grace, SIGKILL); the CLI is
1037
+ // still alive when it returns. Finalizing here would race the live
1038
+ // process for the worktree and git's index.lock, so defer cleanup to
1039
+ // run settlement and consume the abort rejection while we are at it.
1040
+ // The message must not claim a cleanup that has not happened yet.
996
1041
  foregroundAbort.abort();
997
- const lifecycle = safeFinalizeDriveWorktree(managedWorktree);
998
- return appendLifecycleNote(tracked.error, lifecycle);
1042
+ finalizeDriveWorktreeAfter(run, managedWorktree);
1043
+ return managedWorktree
1044
+ ? `${tracked.error}\n\n[worktree lifecycle] Cleaning up ${managedWorktree.session.worktreePath} once ${cliName} exits.`
1045
+ : tracked.error;
999
1046
  }
1000
1047
  ctx?.runYield?.request("background_notification");
1001
1048
  return [
@@ -27,7 +27,7 @@ export async function switchSessionWorkspaceTool(args, ctx) {
27
27
  const target = stringArg(args.target);
28
28
  if (!target)
29
29
  return "Error: target is required";
30
- const bridge = ctx?.workspace;
30
+ const bridge = workspaceBridgeForContext(ctx);
31
31
  if (!bridge) {
32
32
  return ("SwitchSessionWorkspace is not available in this host. " +
33
33
  "Use the host's supported workspace controls instead.");
@@ -48,6 +48,22 @@ export async function switchSessionWorkspaceTool(args, ctx) {
48
48
  return `Error: switching workspace failed: ${err.message}`;
49
49
  }
50
50
  }
51
+ function workspaceBridgeForContext(ctx) {
52
+ if (ctx?.workspaceBridge)
53
+ return ctx.workspaceBridge;
54
+ // Before multi-root authority landed, the host bridge occupied `workspace`.
55
+ // Keep those single-root hosts and injected harnesses working, but never
56
+ // interpret a versioned/root-bearing WorkspaceContext as an executable bridge.
57
+ const legacy = ctx?.workspace;
58
+ if (!legacy || typeof legacy !== "object")
59
+ return undefined;
60
+ if ("version" in legacy || "roots" in legacy || "projectId" in legacy)
61
+ return undefined;
62
+ const candidate = legacy;
63
+ return typeof candidate.switch === "function"
64
+ ? candidate
65
+ : undefined;
66
+ }
51
67
  export const enterWorktreeToolDef = {
52
68
  name: "EnterWorktree",
53
69
  description: "Switch the current session workspace. Target can be a new worktree slug, " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-capability-coding",
3
- "version": "0.8.20",
3
+ "version": "0.9.1",
4
4
  "description": "Coding capability pack for the generic code-shell agent core.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -39,7 +39,7 @@
39
39
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
40
40
  },
41
41
  "dependencies": {
42
- "@cjhyy/code-shell-core": "0.8.20"
42
+ "@cjhyy/code-shell-core": "0.9.1"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=20.10"