@cjhyy/code-shell-core 0.8.20 → 0.9.0

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/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 +35 -0
  9. package/dist/engine/engine-workspace-authority.js +136 -0
  10. package/dist/engine/engine.d.ts +5 -10
  11. package/dist/engine/engine.js +84 -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/chat-session-manager.d.ts +42 -1
  40. package/dist/protocol/chat-session-manager.js +167 -4
  41. package/dist/protocol/chat-session.d.ts +9 -1
  42. package/dist/protocol/chat-session.js +20 -2
  43. package/dist/protocol/mobile-remote-types.d.ts +15 -0
  44. package/dist/protocol/server.d.ts +1 -2
  45. package/dist/protocol/server.js +21 -51
  46. package/dist/protocol/session-workspace-rpc.d.ts +23 -0
  47. package/dist/protocol/session-workspace-rpc.js +171 -0
  48. package/dist/protocol/types.d.ts +45 -1
  49. package/dist/protocol/types.js +4 -0
  50. package/dist/session/session-manager.d.ts +18 -0
  51. package/dist/session/session-manager.js +87 -0
  52. package/dist/tool-system/builtin/cron.d.ts +11 -0
  53. package/dist/tool-system/builtin/cron.js +27 -2
  54. package/dist/tool-system/builtin/edit.js +5 -3
  55. package/dist/tool-system/builtin/write.js +5 -3
  56. package/dist/tool-system/context.d.ts +4 -1
  57. package/dist/tool-system/executor.js +1 -1
  58. package/dist/tool-system/path-policy.d.ts +11 -3
  59. package/dist/tool-system/path-policy.js +56 -36
  60. package/dist/types.d.ts +6 -0
  61. package/dist/workspace/canonical-key.d.ts +7 -0
  62. package/dist/workspace/canonical-key.js +39 -0
  63. package/dist/workspace/workspace-context.d.ts +26 -0
  64. package/dist/workspace/workspace-context.js +112 -0
  65. package/package.json +1 -1
@@ -17,6 +17,11 @@ function fireCronChanged() {
17
17
  // Notifying the host is best-effort; never break the tool on it.
18
18
  }
19
19
  }
20
+ let cronCreateAuthority = null;
21
+ /** Desktop installs a Main-process delegate; other hosts keep the local scheduler path. */
22
+ export function setCronCreateAuthority(authority) {
23
+ cronCreateAuthority = authority;
24
+ }
20
25
  export const cronCreateToolDef = {
21
26
  name: "CronCreate",
22
27
  description: "Create a scheduled automation job that runs a prompt on a recurring schedule. " +
@@ -125,15 +130,33 @@ export async function cronCreateTool(args, context) {
125
130
  // time (the model never supplies it — getCurrentSid() reads the running
126
131
  // Engine's ALS context). Empty/unknown sid → treat as standalone.
127
132
  const resumeSessionId = args.continueInSession === true && getCurrentSid() ? getCurrentSid() : undefined;
133
+ const stableWorkspace = context?.workspace && !context.workspace.projectId.startsWith("legacy-")
134
+ ? {
135
+ projectId: context.workspace.projectId,
136
+ rootId: context.workspace.sessionMainRootId,
137
+ }
138
+ : undefined;
128
139
  let job;
129
140
  try {
130
- job = cronScheduler.create(name, schedule, prompt, {
141
+ const createInput = {
142
+ name,
143
+ schedule,
144
+ prompt,
131
145
  ...(timezone !== undefined ? { timezone } : {}),
132
146
  ...(cwd !== undefined ? { cwd } : {}),
147
+ ...(stableWorkspace ?? {}),
133
148
  ...(permissionLevel !== undefined ? { permissionLevel } : {}),
134
149
  ...(once ? { once: true } : {}),
135
150
  ...(resumeSessionId !== undefined ? { resumeSessionId } : {}),
136
- });
151
+ ...(getCurrentSid() ? { authoritySessionId: getCurrentSid() } : {}),
152
+ };
153
+ if (cronCreateAuthority) {
154
+ job = await cronCreateAuthority(createInput);
155
+ }
156
+ else {
157
+ const { name: _name, schedule: _schedule, prompt: _prompt, authoritySessionId: _authoritySessionId, ...createOptions } = createInput;
158
+ job = cronScheduler.create(name, schedule, prompt, createOptions);
159
+ }
137
160
  }
138
161
  catch (err) {
139
162
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
@@ -162,12 +185,14 @@ export async function cronDeleteTool(args) {
162
185
  const id = args.jobId;
163
186
  if (!id)
164
187
  return "Error: jobId is required";
188
+ cronScheduler.loadJobs({ arm: false });
165
189
  const deleted = cronScheduler.delete(id);
166
190
  if (deleted)
167
191
  fireCronChanged();
168
192
  return deleted ? `Cron job #${id} deleted.` : `Cron job #${id} not found.`;
169
193
  }
170
194
  export async function cronListTool(_args) {
195
+ cronScheduler.loadJobs({ arm: false });
171
196
  const jobs = cronScheduler.list();
172
197
  if (jobs.length === 0)
173
198
  return "No cron jobs scheduled.";
@@ -51,8 +51,10 @@ export async function editTool(args, ctx) {
51
51
  if (!existsSync(filePath))
52
52
  return `Error: File not found: ${filePath}`;
53
53
  try {
54
- const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd);
55
- const beforeRead = revalidateFinalWritePath(filePath, cwd, approvedPath);
54
+ const roots = ctx?.workspace?.roots.map((root) => root.path);
55
+ const digest = ctx?.workspace?.rootsDigest;
56
+ const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd, roots, digest);
57
+ const beforeRead = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
56
58
  if ("error" in beforeRead)
57
59
  return { ok: false, error: beforeRead.error };
58
60
  const raw = await readFile(beforeRead.resolvedPath, "utf-8");
@@ -75,7 +77,7 @@ export async function editTool(args, ctx) {
75
77
  }
76
78
  }
77
79
  const updatedLf = replaceAll ? content.split(oldLf).join(newLf) : content.replace(oldLf, newLf);
78
- const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath);
80
+ const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
79
81
  if ("error" in beforeWrite)
80
82
  return { ok: false, error: beforeWrite.error };
81
83
  await writeFileNoFollow(beforeWrite.resolvedPath, applyEol(updatedLf, eol));
@@ -28,14 +28,16 @@ export async function writeTool(args, ctx) {
28
28
  const cwd = ctx?.cwd ?? process.cwd();
29
29
  const filePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
30
30
  try {
31
- const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd);
32
- const beforeMkdir = revalidateFinalWritePath(filePath, cwd, approvedPath);
31
+ const roots = ctx?.workspace?.roots.map((root) => root.path);
32
+ const digest = ctx?.workspace?.rootsDigest;
33
+ const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd, roots, digest);
34
+ const beforeMkdir = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
33
35
  if ("error" in beforeMkdir)
34
36
  return { ok: false, error: beforeMkdir.error };
35
37
  await mkdir(dirname(beforeMkdir.resolvedPath), { recursive: true });
36
38
  // mkdir may have crossed an existing symlink in a missing parent chain;
37
39
  // resolve again immediately before opening the final file.
38
- const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath);
40
+ const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
39
41
  if ("error" in beforeWrite)
40
42
  return { ok: false, error: beforeWrite.error };
41
43
  await writeFileNoFollow(beforeWrite.resolvedPath, content);
@@ -189,6 +189,7 @@ export interface SubAgentSpawnResult {
189
189
  */
190
190
  export interface ToolVisibilityContext {
191
191
  cwd: string;
192
+ workspace?: import("../workspace/workspace-context.js").WorkspaceContext;
192
193
  hasGoal: boolean;
193
194
  /** Resolved Session identity for per-session extension-tool visibility. */
194
195
  sessionId?: string;
@@ -462,7 +463,9 @@ export interface ToolContext {
462
463
  * switcher. Undefined outside hosts that can switch this conversation's
463
464
  * session workspace.
464
465
  */
465
- workspace?: import("./workspace-bridge.js").WorkspaceBridge;
466
+ workspaceBridge?: import("./workspace-bridge.js").WorkspaceBridge;
467
+ /** Immutable roots authorized for this run. */
468
+ workspace?: import("../workspace/workspace-context.js").WorkspaceContext;
466
469
  /** Host panel discovery/focus bridge. Undefined in non-Desktop/headless engines. */
467
470
  panels?: import("./panel-bridge.js").PanelHostBridge;
468
471
  /**
@@ -319,7 +319,7 @@ export class ToolExecutor {
319
319
  const target = isAbsolutePath(raw) ? raw : resolvePath(this.toolCtx.cwd, raw);
320
320
  call = {
321
321
  ...call,
322
- args: attachFinalWritePathSnapshot(call.args, target, this.toolCtx.cwd),
322
+ args: attachFinalWritePathSnapshot(call.args, target, this.toolCtx.cwd, this.toolCtx.workspace?.roots.map((root) => root.path), this.toolCtx.workspace?.rootsDigest),
323
323
  };
324
324
  }
325
325
  const pathPolicyError = await this.enforceDeclaredPathPolicy(toolDef, call.args);
@@ -42,10 +42,14 @@ export interface PathClassification {
42
42
  reason: string;
43
43
  /** Resolved absolute path (with symlinks followed when possible). */
44
44
  resolvedPath: string;
45
+ /** Canonical workspace root containing resolvedPath, when one matched. */
46
+ matchedRoot?: string;
45
47
  }
46
48
  export interface ClassifyOptions {
47
49
  /** Absolute path of the active workspace (Engine.cwd). */
48
50
  workspaceRoot: string;
51
+ /** Complete authorized root set. Omitted preserves legacy single-root behavior. */
52
+ workspaceRoots?: readonly string[];
49
53
  /** "read" or "write" — different defaults for sensitive paths. */
50
54
  operation: PathOperation;
51
55
  }
@@ -53,20 +57,24 @@ export interface FinalWritePathSnapshot {
53
57
  resolvedPath: string;
54
58
  workspacePath: string;
55
59
  insideWorkspace: boolean;
60
+ matchedRoot?: string;
61
+ rootsDigest?: string;
56
62
  }
57
63
  export type PathApprovalScope = "once" | "session" | "project";
58
64
  /** Test seam: clear the in-memory session grants. */
59
65
  export declare function _resetSessionPathGrants(): void;
60
66
  export declare function openSessionPathApprovals(sessionId: string): void;
61
67
  export declare function clearSessionPathApprovals(sessionId: string): void;
68
+ /** Revoke only grants that point into a root removed from a live project. */
69
+ export declare function clearSessionPathApprovalsUnderRoot(sessionId: string, root: string): void;
62
70
  /**
63
71
  * Capture the concrete target approved by the executor's first path-policy
64
72
  * pass. The snapshot is carried on the internal args object via a symbol, so
65
73
  * it cannot collide with an LLM-supplied schema property or leak into logs.
66
74
  */
67
- export declare function attachFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string): Record<string, unknown>;
68
- export declare function getFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string): FinalWritePathSnapshot;
69
- export declare function revalidateFinalWritePath(filePath: string, workspaceRoot: string, approved: FinalWritePathSnapshot): {
75
+ export declare function attachFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string, workspaceRoots?: readonly string[], rootsDigest?: string): Record<string, unknown>;
76
+ export declare function getFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string, workspaceRoots?: readonly string[], rootsDigest?: string): FinalWritePathSnapshot;
77
+ export declare function revalidateFinalWritePath(filePath: string, workspaceRoot: string, approved: FinalWritePathSnapshot, workspaceRoots?: readonly string[], rootsDigest?: string): {
70
78
  resolvedPath: string;
71
79
  } | {
72
80
  error: string;
@@ -33,8 +33,9 @@ import { constants, realpathSync, existsSync, readFileSync, writeFileSync, mkdir
33
33
  import { open } from "node:fs/promises";
34
34
  import { homedir } from "node:os";
35
35
  import { randomUUID } from "node:crypto";
36
- import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
36
+ import { dirname, join, relative, sep } from "node:path";
37
37
  import { readInstalledPlugins } from "../plugins/installedPlugins.js";
38
+ import { canonicalPath } from "../workspace/canonical-key.js";
38
39
  const FINAL_WRITE_PATH_SNAPSHOT = Symbol("codeshell.finalWritePathSnapshot");
39
40
  /**
40
41
  * Default sensitive path patterns. These are evaluated AFTER home-expansion
@@ -291,6 +292,20 @@ export function clearSessionPathApprovals(sessionId) {
291
292
  closedPathApprovalSessions.add(sessionId);
292
293
  askChains.delete(sessionId);
293
294
  }
295
+ /** Revoke only grants that point into a root removed from a live project. */
296
+ export function clearSessionPathApprovalsUnderRoot(sessionId, root) {
297
+ const grants = sessionPathGrants.get(sessionId);
298
+ if (!grants)
299
+ return;
300
+ const canonicalRoot = safeRealpath(root);
301
+ for (const grant of [...grants]) {
302
+ const grantPath = safeRealpath(grant.prefix);
303
+ if (isInsideDir(grantPath, canonicalRoot))
304
+ grants.delete(grant);
305
+ }
306
+ if (grants.size === 0)
307
+ sessionPathGrants.delete(sessionId);
308
+ }
294
309
  /**
295
310
  * Best-effort resolution. realpath fails when the path doesn't exist yet —
296
311
  * the common case for Write creating a new file. We walk up to the nearest
@@ -303,47 +318,28 @@ export function clearSessionPathApprovals(sessionId) {
303
318
  * would be misclassified as outside-workspace.
304
319
  */
305
320
  function safeRealpath(p) {
306
- const abs = isAbsolute(p) ? p : resolvePath(process.cwd(), p);
307
- // Walk up to the nearest existing ancestor.
308
- let candidate = abs;
309
- const segments = [];
310
- // Cap the walk so a pathological input can't spin forever.
311
- for (let i = 0; i < 64; i++) {
312
- try {
313
- const resolved = realpathSync(candidate);
314
- if (segments.length === 0)
315
- return resolved;
316
- return resolvePath(resolved, ...segments.reverse());
317
- }
318
- catch {
319
- const parent = dirname(candidate);
320
- if (parent === candidate) {
321
- // Reached root without finding anything that exists — return the
322
- // original absolute form so the caller still has a usable path.
323
- return abs;
324
- }
325
- segments.push(candidate.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
326
- candidate = parent;
327
- }
328
- }
329
- return abs;
321
+ return canonicalPath(p);
330
322
  }
331
323
  /**
332
324
  * Capture the concrete target approved by the executor's first path-policy
333
325
  * pass. The snapshot is carried on the internal args object via a symbol, so
334
326
  * it cannot collide with an LLM-supplied schema property or leak into logs.
335
327
  */
336
- export function attachFinalWritePathSnapshot(args, filePath, workspaceRoot) {
328
+ export function attachFinalWritePathSnapshot(args, filePath, workspaceRoot, workspaceRoots, rootsDigest) {
337
329
  const resolvedPath = safeRealpath(filePath);
338
330
  const workspacePath = safeRealpath(workspaceRoot);
331
+ const roots = (workspaceRoots?.length ? workspaceRoots : [workspaceRoot]).map(safeRealpath);
332
+ const matchedRoot = roots.find((root) => isInsideDir(resolvedPath, root));
339
333
  const snapshot = {
340
334
  resolvedPath,
341
335
  workspacePath,
342
- insideWorkspace: isInsideDir(resolvedPath, workspacePath),
336
+ insideWorkspace: matchedRoot !== undefined,
337
+ matchedRoot,
338
+ rootsDigest,
343
339
  };
344
340
  return { ...args, [FINAL_WRITE_PATH_SNAPSHOT]: snapshot };
345
341
  }
346
- export function getFinalWritePathSnapshot(args, filePath, workspaceRoot) {
342
+ export function getFinalWritePathSnapshot(args, filePath, workspaceRoot, workspaceRoots, rootsDigest) {
347
343
  const carried = args[FINAL_WRITE_PATH_SNAPSHOT];
348
344
  if (carried && typeof carried === "object") {
349
345
  return carried;
@@ -353,19 +349,32 @@ export function getFinalWritePathSnapshot(args, filePath, workspaceRoot) {
353
349
  // read-to-write interval with a second check before its writeFile.
354
350
  const resolvedPath = safeRealpath(filePath);
355
351
  const workspacePath = safeRealpath(workspaceRoot);
352
+ const roots = (workspaceRoots?.length ? workspaceRoots : [workspaceRoot]).map(safeRealpath);
353
+ const matchedRoot = roots.find((root) => isInsideDir(resolvedPath, root));
356
354
  return {
357
355
  resolvedPath,
358
356
  workspacePath,
359
- insideWorkspace: isInsideDir(resolvedPath, workspacePath),
357
+ insideWorkspace: matchedRoot !== undefined,
358
+ matchedRoot,
359
+ rootsDigest,
360
360
  };
361
361
  }
362
- export function revalidateFinalWritePath(filePath, workspaceRoot, approved) {
362
+ export function revalidateFinalWritePath(filePath, workspaceRoot, approved, workspaceRoots, rootsDigest) {
363
363
  const currentPath = safeRealpath(filePath);
364
364
  const currentWorkspace = safeRealpath(workspaceRoot);
365
365
  const sameTarget = normPath(currentPath) === normPath(approved.resolvedPath);
366
366
  const sameWorkspace = normPath(currentWorkspace) === normPath(approved.workspacePath);
367
- const crossedWorkspaceBoundary = approved.insideWorkspace && !isInsideDir(currentPath, currentWorkspace);
368
- if (crossedWorkspaceBoundary || !sameTarget || !sameWorkspace) {
367
+ const currentRoots = (workspaceRoots?.length ? workspaceRoots : [workspaceRoot]).map(safeRealpath);
368
+ const currentMatchedRoot = currentRoots.find((root) => isInsideDir(currentPath, root));
369
+ const crossedWorkspaceBoundary = approved.insideWorkspace && currentMatchedRoot === undefined;
370
+ const changedMatchedRoot = approved.matchedRoot !== undefined &&
371
+ normPath(currentMatchedRoot ?? "") !== normPath(approved.matchedRoot);
372
+ const changedRootsDigest = approved.rootsDigest !== undefined && rootsDigest !== approved.rootsDigest;
373
+ if (crossedWorkspaceBoundary ||
374
+ changedMatchedRoot ||
375
+ changedRootsDigest ||
376
+ !sameTarget ||
377
+ !sameWorkspace) {
369
378
  const reason = crossedWorkspaceBoundary
370
379
  ? "final write path resolved outside the workspace after approval"
371
380
  : "final write path changed after approval";
@@ -620,11 +629,13 @@ export function classifyPath(rawPath, opts) {
620
629
  }
621
630
  const expanded = expandTilde(rawPath);
622
631
  const resolved = safeRealpath(expanded);
623
- const workspace = safeRealpath(opts.workspaceRoot);
632
+ const workspaceRoots = opts.workspaceRoots?.length ? opts.workspaceRoots : [opts.workspaceRoot];
633
+ const workspaces = workspaceRoots.map((root) => safeRealpath(root));
624
634
  const sensitiveDir = matchSensitiveDir(resolved);
625
635
  const sensitiveFile = matchSensitiveFile(resolved);
626
636
  const sensitiveLabel = sensitiveDir ?? sensitiveFile;
627
- const insideWorkspace = isInsideDir(resolved, workspace);
637
+ const matchedRoot = workspaces.find((workspace) => isInsideDir(resolved, workspace));
638
+ const insideWorkspace = matchedRoot !== undefined;
628
639
  // Registered Skill resources are managed runtime inputs. Their SKILL.md is
629
640
  // already readable through the Skill builtin; allow its contained reference
630
641
  // files through the ordinary Read tool as well. A credential-shaped basename
@@ -675,7 +686,12 @@ export function classifyPath(rawPath, opts) {
675
686
  };
676
687
  }
677
688
  if (insideWorkspace) {
678
- return { decision: "allow", reason: "inside workspace", resolvedPath: resolved };
689
+ return {
690
+ decision: "allow",
691
+ reason: "inside workspace",
692
+ resolvedPath: resolved,
693
+ matchedRoot,
694
+ };
679
695
  }
680
696
  // Outside workspace: ask for both read and write. The conservative bias
681
697
  // matches the plan's leaning answer to Q1 — ask on sensitive reads, deny
@@ -732,7 +748,11 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
732
748
  // who chose full access still got prompted for project-external reads.
733
749
  if (ctx.permissionMode === "bypassPermissions")
734
750
  return null;
735
- const c = classifyPath(filePath, { workspaceRoot: ctx.cwd, operation });
751
+ const c = classifyPath(filePath, {
752
+ workspaceRoot: ctx.cwd,
753
+ workspaceRoots: ctx.workspace?.roots.map((root) => root.path),
754
+ operation,
755
+ });
736
756
  if (c.decision === "allow")
737
757
  return null;
738
758
  if (c.decision === "deny") {
package/dist/types.d.ts CHANGED
@@ -188,6 +188,10 @@ export interface SessionWorkspace {
188
188
  createdBy: "codeshell";
189
189
  };
190
190
  }
191
+ export interface SessionProjectBinding {
192
+ projectId: string;
193
+ mainRootId: string;
194
+ }
191
195
  export interface ContextUsageAnchor {
192
196
  promptTokens: number;
193
197
  messageCount: number;
@@ -231,6 +235,8 @@ export interface SessionState {
231
235
  cwd: string;
232
236
  /** Current main/worktree execution pointer. Absent only on legacy state.json files. */
233
237
  workspace?: SessionWorkspace;
238
+ /** Stable Desktop project identity. Absent on legacy/no-repo sessions. */
239
+ project?: SessionProjectBinding;
234
240
  startedAt: number;
235
241
  model: string;
236
242
  provider: string;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Resolve a path through symlinks without requiring the leaf to exist.
3
+ * Missing suffixes are appended to the nearest existing realpathed ancestor.
4
+ */
5
+ export declare function canonicalPath(input: string): string;
6
+ /** Stable comparison key for project roots and workspace containment decisions. */
7
+ export declare function canonicalKey(input: string): string;
@@ -0,0 +1,39 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, parse, resolve } from "node:path";
3
+ const CASE_INSENSITIVE_PLATFORM = process.platform === "darwin" || process.platform === "win32";
4
+ /**
5
+ * Resolve a path through symlinks without requiring the leaf to exist.
6
+ * Missing suffixes are appended to the nearest existing realpathed ancestor.
7
+ */
8
+ export function canonicalPath(input) {
9
+ const absolute = isAbsolute(input) ? resolve(input) : resolve(process.cwd(), input);
10
+ let candidate = absolute;
11
+ const suffix = [];
12
+ for (let depth = 0; depth < 64; depth += 1) {
13
+ try {
14
+ const existing = realpathSync(candidate);
15
+ const joined = suffix.length > 0 ? resolve(existing, ...suffix) : resolve(existing);
16
+ return trimTrailingSeparators(joined);
17
+ }
18
+ catch {
19
+ const parent = dirname(candidate);
20
+ if (parent === candidate)
21
+ return trimTrailingSeparators(absolute);
22
+ suffix.unshift(basename(candidate));
23
+ candidate = parent;
24
+ }
25
+ }
26
+ return trimTrailingSeparators(absolute);
27
+ }
28
+ /** Stable comparison key for project roots and workspace containment decisions. */
29
+ export function canonicalKey(input) {
30
+ const normalized = canonicalPath(input);
31
+ return CASE_INSENSITIVE_PLATFORM ? normalized.toLocaleLowerCase("en-US") : normalized;
32
+ }
33
+ function trimTrailingSeparators(input) {
34
+ const root = parse(input).root;
35
+ let end = input.length;
36
+ while (end > root.length && (input[end - 1] === "/" || input[end - 1] === "\\"))
37
+ end -= 1;
38
+ return input.slice(0, end);
39
+ }
@@ -0,0 +1,26 @@
1
+ import { canonicalKey, canonicalPath } from "./canonical-key.js";
2
+ export { canonicalKey, canonicalPath };
3
+ export type ProjectId = string;
4
+ export type ProjectRootId = string;
5
+ export interface ProjectRootContext {
6
+ id: ProjectRootId;
7
+ path: string;
8
+ role: "primary" | "secondary";
9
+ }
10
+ export interface WorkspaceContext {
11
+ version: 1;
12
+ projectId: ProjectId;
13
+ projectRevision: number;
14
+ sessionMainRootId: ProjectRootId;
15
+ roots: ProjectRootContext[];
16
+ rootsDigest: string;
17
+ }
18
+ export type WorkspaceContextInput = Omit<WorkspaceContext, "version" | "rootsDigest">;
19
+ export declare function computeWorkspaceRootsDigest(roots: readonly ProjectRootContext[]): string;
20
+ export declare function createWorkspaceContext(input: WorkspaceContextInput): WorkspaceContext;
21
+ export declare function validateWorkspaceContext(value: unknown): WorkspaceContext;
22
+ /** Compatibility context for callers that only possess one cwd. It is never persisted as a binding. */
23
+ export declare function legacySingleRootWorkspace(cwd: string): WorkspaceContext;
24
+ export declare function workspacePrimaryRoot(context: WorkspaceContext): ProjectRootContext;
25
+ /** Paths present in the previous run-scoped root set but absent from the next one. */
26
+ export declare function removedWorkspaceRootPaths(previous: Pick<WorkspaceContext, "roots">, next: Pick<WorkspaceContext, "roots">): string[];
@@ -0,0 +1,112 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, relative, sep } from "node:path";
3
+ import { canonicalKey, canonicalPath } from "./canonical-key.js";
4
+ export { canonicalKey, canonicalPath };
5
+ function requireIdentifier(value, label) {
6
+ if (typeof value !== "string" || value.length === 0 || value.length > 512) {
7
+ throw new Error(`invalid WorkspaceContext ${label}`);
8
+ }
9
+ return value;
10
+ }
11
+ function containsPath(parent, child) {
12
+ if (parent === child)
13
+ return true;
14
+ const rel = relative(parent, child);
15
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
16
+ }
17
+ export function computeWorkspaceRootsDigest(roots) {
18
+ const keys = roots.map((root) => canonicalKey(root.path)).sort();
19
+ return createHash("sha256").update(keys.join("\0"), "utf8").digest("hex");
20
+ }
21
+ function validateRoots(roots, sessionMainRootId) {
22
+ if (roots.length === 0 || roots.length > 256) {
23
+ throw new Error("WorkspaceContext roots must contain between 1 and 256 entries");
24
+ }
25
+ const ids = new Set();
26
+ const normalized = roots.map((root, index) => {
27
+ if (!root || typeof root !== "object") {
28
+ throw new Error(`invalid WorkspaceContext root at index ${index}`);
29
+ }
30
+ const id = requireIdentifier(root.id, `roots[${index}].id`);
31
+ if (ids.has(id))
32
+ throw new Error(`duplicate root id: ${id}`);
33
+ ids.add(id);
34
+ if (typeof root.path !== "string" || root.path.length === 0 || root.path.length > 16_384) {
35
+ throw new Error(`invalid WorkspaceContext roots[${index}].path`);
36
+ }
37
+ if (!isAbsolute(root.path)) {
38
+ throw new Error(`WorkspaceContext root path must be absolute: ${root.path}`);
39
+ }
40
+ if (root.role !== "primary" && root.role !== "secondary") {
41
+ throw new Error(`invalid WorkspaceContext roots[${index}].role`);
42
+ }
43
+ return { id, path: root.path, role: root.role };
44
+ });
45
+ const primary = normalized.filter((root) => root.role === "primary");
46
+ if (primary.length !== 1 || primary[0]?.id !== sessionMainRootId) {
47
+ throw new Error("WorkspaceContext must have exactly one primary matching sessionMainRootId");
48
+ }
49
+ const keys = normalized.map((root) => canonicalKey(root.path));
50
+ for (let left = 0; left < keys.length; left += 1) {
51
+ for (let right = left + 1; right < keys.length; right += 1) {
52
+ if (containsPath(keys[left], keys[right]) || containsPath(keys[right], keys[left])) {
53
+ throw new Error(`WorkspaceContext roots overlap: ${normalized[left].path} and ${normalized[right].path}`);
54
+ }
55
+ }
56
+ }
57
+ return normalized;
58
+ }
59
+ export function createWorkspaceContext(input) {
60
+ const projectId = requireIdentifier(input.projectId, "projectId");
61
+ const sessionMainRootId = requireIdentifier(input.sessionMainRootId, "sessionMainRootId");
62
+ if (!Number.isSafeInteger(input.projectRevision) || input.projectRevision < 1) {
63
+ throw new Error("invalid WorkspaceContext projectRevision");
64
+ }
65
+ const roots = validateRoots(input.roots, sessionMainRootId);
66
+ return {
67
+ version: 1,
68
+ projectId,
69
+ projectRevision: input.projectRevision,
70
+ sessionMainRootId,
71
+ roots,
72
+ rootsDigest: computeWorkspaceRootsDigest(roots),
73
+ };
74
+ }
75
+ export function validateWorkspaceContext(value) {
76
+ if (!value || typeof value !== "object")
77
+ throw new Error("invalid WorkspaceContext");
78
+ const candidate = value;
79
+ if (candidate.version !== 1)
80
+ throw new Error("invalid WorkspaceContext version");
81
+ const created = createWorkspaceContext({
82
+ projectId: candidate.projectId,
83
+ projectRevision: candidate.projectRevision,
84
+ sessionMainRootId: candidate.sessionMainRootId,
85
+ roots: candidate.roots,
86
+ });
87
+ if (candidate.rootsDigest !== created.rootsDigest) {
88
+ throw new Error("WorkspaceContext rootsDigest does not match roots");
89
+ }
90
+ return created;
91
+ }
92
+ /** Compatibility context for callers that only possess one cwd. It is never persisted as a binding. */
93
+ export function legacySingleRootWorkspace(cwd) {
94
+ const path = canonicalPath(cwd);
95
+ const keyDigest = createHash("sha256").update(canonicalKey(path), "utf8").digest("hex");
96
+ return createWorkspaceContext({
97
+ projectId: `legacy-${keyDigest}`,
98
+ projectRevision: 1,
99
+ sessionMainRootId: "legacy-root",
100
+ roots: [{ id: "legacy-root", path, role: "primary" }],
101
+ });
102
+ }
103
+ export function workspacePrimaryRoot(context) {
104
+ return context.roots.find((root) => root.id === context.sessionMainRootId);
105
+ }
106
+ /** Paths present in the previous run-scoped root set but absent from the next one. */
107
+ export function removedWorkspaceRootPaths(previous, next) {
108
+ const nextKeys = new Set(next.roots.map((root) => canonicalKey(root.path)));
109
+ return previous.roots
110
+ .filter((root) => !nextKeys.has(canonicalKey(root.path)))
111
+ .map((root) => root.path);
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.8.20",
3
+ "version": "0.9.0",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",