@cjhyy/code-shell-core 0.8.9 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +45 -6
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-types.d.ts +9 -0
  10. package/dist/engine/turn-loop.js +9 -8
  11. package/dist/goal/lifecycle.d.ts +2 -0
  12. package/dist/goal/lifecycle.js +56 -33
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.internal.d.ts +1 -0
  15. package/dist/index.internal.js +1 -0
  16. package/dist/index.js +2 -2
  17. package/dist/links/cli.d.ts +2 -0
  18. package/dist/links/cli.js +11 -4
  19. package/dist/model-catalog/index.js +19 -4
  20. package/dist/model-catalog/save-entry.js +122 -61
  21. package/dist/model-catalog/types.js +27 -23
  22. package/dist/panel-apps/installer.js +27 -14
  23. package/dist/panel-apps/registry.js +60 -12
  24. package/dist/plugins/installedPlugins.d.ts +4 -0
  25. package/dist/plugins/installedPlugins.js +70 -30
  26. package/dist/plugins/installer/types.d.ts +12 -12
  27. package/dist/plugins/installer/update.js +37 -38
  28. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  29. package/dist/plugins/knownMarketplaces.js +127 -23
  30. package/dist/plugins/pluginCatalog.js +18 -4
  31. package/dist/plugins/pluginHookApproval.js +56 -60
  32. package/dist/plugins/pluginMcpApproval.js +50 -52
  33. package/dist/profile/catalog-store.js +39 -4
  34. package/dist/profile/catalog.js +55 -15
  35. package/dist/profile/store.js +51 -21
  36. package/dist/protocol/chat-session-manager.d.ts +9 -0
  37. package/dist/protocol/chat-session-manager.js +13 -0
  38. package/dist/protocol/chat-session.d.ts +5 -0
  39. package/dist/protocol/chat-session.js +1 -0
  40. package/dist/protocol/server.d.ts +2 -0
  41. package/dist/protocol/server.js +75 -29
  42. package/dist/protocol/types.d.ts +8 -0
  43. package/dist/run/FileRunStore.d.ts +2 -0
  44. package/dist/run/FileRunStore.js +153 -18
  45. package/dist/run/Heartbeat.js +63 -4
  46. package/dist/services/auto-dream.js +39 -17
  47. package/dist/services/session-memory.js +107 -8
  48. package/dist/session/file-history.d.ts +63 -2
  49. package/dist/session/file-history.js +593 -86
  50. package/dist/session/session-manager.d.ts +1 -0
  51. package/dist/session/session-manager.js +52 -21
  52. package/dist/session/transcript.js +33 -3
  53. package/dist/session/undo-target.d.ts +15 -6
  54. package/dist/session/undo-target.js +26 -9
  55. package/dist/settings/manager.d.ts +22 -3
  56. package/dist/settings/manager.js +185 -50
  57. package/dist/settings/schema.d.ts +3 -3
  58. package/dist/sources/adapters/local-files.js +49 -4
  59. package/dist/sources/catalog.js +64 -18
  60. package/dist/sources/types.d.ts +3 -3
  61. package/dist/sources/types.js +7 -4
  62. package/dist/themes/installer.js +192 -28
  63. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  64. package/dist/tool-system/builtin/cron.d.ts +2 -1
  65. package/dist/tool-system/builtin/cron.js +20 -6
  66. package/dist/tool-system/builtin/index.js +44 -0
  67. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  68. package/dist/tool-system/builtin/install-capability.js +1057 -0
  69. package/dist/tool-system/builtin/skill.js +3 -1
  70. package/dist/tool-system/executor.js +1 -0
  71. package/dist/tool-system/registry.js +5 -0
  72. package/dist/utils/file-mutex.d.ts +2 -0
  73. package/dist/utils/file-mutex.js +29 -4
  74. package/package.json +2 -1
@@ -11,6 +11,25 @@
11
11
  * so an unattended goal run can't burn tokens or wall time without bound.
12
12
  */
13
13
  import { createHash, randomUUID } from "node:crypto";
14
+ function positiveSafeNumber(value) {
15
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= Number.MAX_SAFE_INTEGER;
16
+ }
17
+ function positiveSafeInteger(value) {
18
+ if (!positiveSafeNumber(value))
19
+ return undefined;
20
+ const integer = Math.floor(value);
21
+ return integer >= 1 ? integer : undefined;
22
+ }
23
+ /** Add a user-controlled positive integer delta without producing Infinity or an unsafe integer. */
24
+ export function extendGoalLimit(current, addition) {
25
+ const delta = positiveSafeInteger(addition);
26
+ if (delta === undefined)
27
+ return current;
28
+ const safeCurrent = typeof current === "number" && Number.isFinite(current) && current >= 0
29
+ ? Math.min(Number.MAX_SAFE_INTEGER, Math.floor(current))
30
+ : Number.MAX_SAFE_INTEGER;
31
+ return Math.min(Number.MAX_SAFE_INTEGER, safeCurrent + delta);
32
+ }
14
33
  function goalLifecycleConfig(goal) {
15
34
  const { goalId: _goalId, revision: _revision, paused: _paused, ...config } = goal;
16
35
  return config;
@@ -22,7 +41,7 @@ export function createGoalLifecycle(goal, phase = goal.paused === true
22
41
  const base = {
23
42
  version: 1,
24
43
  goalId: goal.goalId ?? randomUUID(),
25
- revision: Math.max(1, Math.floor(goal.revision ?? 1)),
44
+ revision: positiveSafeInteger(goal.revision) ?? 1,
26
45
  config: goalLifecycleConfig(goal),
27
46
  updatedAtMs: nowMs,
28
47
  };
@@ -53,7 +72,7 @@ export function decodeGoalLifecycle(value) {
53
72
  if (typeof candidate.goalId !== "string" || candidate.goalId.length === 0)
54
73
  return undefined;
55
74
  if (typeof candidate.revision !== "number" ||
56
- !Number.isInteger(candidate.revision) ||
75
+ !Number.isSafeInteger(candidate.revision) ||
57
76
  candidate.revision < 1) {
58
77
  return undefined;
59
78
  }
@@ -80,17 +99,19 @@ export function decodeGoalLifecycle(value) {
80
99
  for (const key of ["tokenBudget", "timeBudgetMs", "maxTurns", "maxStopBlocks"]) {
81
100
  const field = config[key];
82
101
  if (field !== undefined &&
83
- (typeof field !== "number" || !Number.isFinite(field) || field <= 0)) {
102
+ !positiveSafeNumber(field)) {
84
103
  return undefined;
85
104
  }
86
105
  }
87
106
  for (const key of ["maxTurns", "maxStopBlocks"]) {
88
107
  const field = config[key];
89
- if (field !== undefined && !Number.isInteger(field))
108
+ if (field !== undefined && !Number.isSafeInteger(field))
90
109
  return undefined;
91
110
  }
92
111
  if (config.setAtMs !== undefined &&
93
- (typeof config.setAtMs !== "number" || !Number.isFinite(config.setAtMs) || config.setAtMs < 0)) {
112
+ (typeof config.setAtMs !== "number" ||
113
+ !Number.isSafeInteger(config.setAtMs) ||
114
+ config.setAtMs < 0)) {
94
115
  return undefined;
95
116
  }
96
117
  const phase = candidate.phase;
@@ -309,11 +330,12 @@ export const INTERACTIVE_DEFAULT_MAX_STOP_BLOCKS = 8;
309
330
  * Pure + injectable so engine and tests agree.
310
331
  */
311
332
  export function resolveMaxStopBlocks(configMaxStopBlocks, goal) {
312
- if (typeof configMaxStopBlocks === "number" && configMaxStopBlocks > 0) {
313
- return Math.floor(configMaxStopBlocks);
314
- }
315
- if (goal?.maxStopBlocks && goal.maxStopBlocks > 0)
316
- return goal.maxStopBlocks;
333
+ const configLimit = positiveSafeInteger(configMaxStopBlocks);
334
+ if (configLimit !== undefined)
335
+ return configLimit;
336
+ const goalLimit = positiveSafeInteger(goal?.maxStopBlocks);
337
+ if (goalLimit !== undefined)
338
+ return goalLimit;
317
339
  return goal ? GOAL_DEFAULT_MAX_STOP_BLOCKS : INTERACTIVE_DEFAULT_MAX_STOP_BLOCKS;
318
340
  }
319
341
  /**
@@ -340,24 +362,25 @@ export function normalizeGoal(raw) {
340
362
  const out = { objective };
341
363
  if (typeof obj.goalId === "string" && obj.goalId.trim())
342
364
  out.goalId = obj.goalId;
343
- if (typeof obj.revision === "number" && obj.revision > 0) {
344
- out.revision = Math.floor(obj.revision);
345
- }
365
+ const revision = positiveSafeInteger(obj.revision);
366
+ if (revision !== undefined)
367
+ out.revision = revision;
346
368
  if (obj.paused === true)
347
369
  out.paused = true;
348
- if (typeof obj.tokenBudget === "number" && obj.tokenBudget > 0)
370
+ if (positiveSafeNumber(obj.tokenBudget))
349
371
  out.tokenBudget = obj.tokenBudget;
350
- if (typeof obj.timeBudgetMs === "number" && obj.timeBudgetMs > 0)
372
+ if (positiveSafeNumber(obj.timeBudgetMs))
351
373
  out.timeBudgetMs = obj.timeBudgetMs;
352
- if (typeof obj.maxTurns === "number" && obj.maxTurns > 0)
353
- out.maxTurns = Math.floor(obj.maxTurns);
354
- if (typeof obj.maxStopBlocks === "number" && obj.maxStopBlocks > 0) {
355
- out.maxStopBlocks = Math.floor(obj.maxStopBlocks);
356
- }
374
+ const maxTurns = positiveSafeInteger(obj.maxTurns);
375
+ if (maxTurns !== undefined)
376
+ out.maxTurns = maxTurns;
377
+ const maxStopBlocks = positiveSafeInteger(obj.maxStopBlocks);
378
+ if (maxStopBlocks !== undefined)
379
+ out.maxStopBlocks = maxStopBlocks;
357
380
  // Preserve a positive goal-set timestamp so a resumed/inherited goal keeps
358
381
  // the anchor for relative deadlines. Non-positive/junk is dropped, matching
359
382
  // the budget fields (a bogus anchor is worse than none).
360
- if (typeof obj.setAtMs === "number" && obj.setAtMs > 0)
383
+ if (positiveSafeInteger(obj.setAtMs) !== undefined)
361
384
  out.setAtMs = obj.setAtMs;
362
385
  return out;
363
386
  }
@@ -391,10 +414,11 @@ export const INTERACTIVE_DEFAULT_MAX_TURNS = 100;
391
414
  * Pure + injectable so the engine and tests agree on the rule.
392
415
  */
393
416
  export function resolveMaxTurns(configMaxTurns, goal) {
394
- if (typeof configMaxTurns === "number" && configMaxTurns > 0)
395
- return configMaxTurns;
417
+ const configLimit = positiveSafeInteger(configMaxTurns);
418
+ if (configLimit !== undefined)
419
+ return configLimit;
396
420
  if (goal)
397
- return goal.maxTurns ?? GOAL_DEFAULT_MAX_TURNS;
421
+ return positiveSafeInteger(goal.maxTurns) ?? GOAL_DEFAULT_MAX_TURNS;
398
422
  return INTERACTIVE_DEFAULT_MAX_TURNS;
399
423
  }
400
424
  /** Warn when this many turns (or fewer) remain before maxTurns. */
@@ -436,19 +460,17 @@ export function limitProximity(turnCount, maxTurns, stopBlockCount, maxStopBlock
436
460
  */
437
461
  export function applyGoalExtension(currentMaxTurns, goal, tokensUsed, elapsedMs, ext) {
438
462
  let maxTurns = currentMaxTurns;
439
- if (typeof ext.addTurns === "number" && ext.addTurns > 0) {
440
- maxTurns += Math.floor(ext.addTurns);
441
- }
463
+ maxTurns = extendGoalLimit(maxTurns, ext.addTurns);
442
464
  let tokenBudget = goal?.tokenBudget;
443
465
  let timeBudgetMs = goal?.timeBudgetMs;
444
466
  if (goal) {
445
- if (typeof ext.addTokenBudget === "number" && ext.addTokenBudget > 0) {
446
- tokenBudget = (tokenBudget ?? tokensUsed) + Math.floor(ext.addTokenBudget);
467
+ if (positiveSafeInteger(ext.addTokenBudget) !== undefined) {
468
+ tokenBudget = extendGoalLimit(tokenBudget ?? tokensUsed, ext.addTokenBudget);
447
469
  }
448
- if (typeof ext.addTimeBudgetMs === "number" && ext.addTimeBudgetMs > 0) {
470
+ if (positiveSafeInteger(ext.addTimeBudgetMs) !== undefined) {
449
471
  // Seed an unset cap from elapsed time (not 0), mirroring the token branch,
450
472
  // so the new cap lands above current usage. Fixes B1.
451
- timeBudgetMs = (timeBudgetMs ?? elapsedMs) + Math.floor(ext.addTimeBudgetMs);
473
+ timeBudgetMs = extendGoalLimit(timeBudgetMs ?? elapsedMs, ext.addTimeBudgetMs);
452
474
  }
453
475
  }
454
476
  return { maxTurns, tokenBudget, timeBudgetMs };
@@ -459,8 +481,9 @@ export function createGoalBudgetTracker(goal, nowMs) {
459
481
  }
460
482
  /** Add this turn's token usage to the running total. */
461
483
  export function recordGoalUsage(tracker, turnTokens) {
462
- if (turnTokens > 0)
463
- tracker.tokensUsed += turnTokens;
484
+ if (positiveSafeInteger(turnTokens) !== undefined) {
485
+ tracker.tokensUsed = extendGoalLimit(tracker.tokensUsed, turnTokens);
486
+ }
464
487
  }
465
488
  /**
466
489
  * Has the run exceeded any configured budget? `nowMs` is injected so callers
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.8.9";
6
+ export declare const VERSION = "0.8.10";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
9
9
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
@@ -66,7 +66,7 @@ export { Methods, ErrorCodes, type RpcMessage, type RunResult, type ForkSessionP
66
66
  export type * from "./protocol/mobile-remote-types.js";
67
67
  export { Transcript } from "./session/transcript.js";
68
68
  export { SessionManager, sessionMainRoot, codeShellHome, sessionsRoot, buildForkState, buildForkTranscript, type ForkSessionOptions, type ForkSessionResult, } from "./session/session-manager.js";
69
- export { FileHistory } from "./session/file-history.js";
69
+ export { FileHistory, type FileSnapshot, type RedoRecord, type TurnUndoPlan, } from "./session/file-history.js";
70
70
  export { latestUndoTarget, earliestSnapshotsPerFile, latestTurnUndoTargets, latestRedoTargets, } from "./session/undo-target.js";
71
71
  export { diffLines, renderDiffPreview, type DiffLine } from "./session/simple-diff.js";
72
72
  export { MemoryManager } from "./session/memory.js";
@@ -74,7 +74,6 @@ export type { MemoryEntry, MemoryOrigin, MemoryScope } from "./session/memory.js
74
74
  export type { RouteSessionMessageInput, SessionMessageRouter, SessionMessageTarget, SessionMessageToolService, } from "./session/session-message.js";
75
75
  export { runDreamConsolidation, type DreamConsolidationInput, type DreamConsolidationResult, } from "./services/dream-consolidation.js";
76
76
  export { authorize, refreshToken, generatePKCE, createHardenedOAuthFetch, type OAuthConfig, type OAuthTokens, type OAuthAuthorizeOptions, type OAuthRefreshOptions, type HardenedOAuthFetchOptions, } from "./services/oauth.js";
77
- export type { FileSnapshot, RedoRecord } from "./session/file-history.js";
78
77
  export { PromptComposer } from "./prompt/composer.js";
79
78
  export { SectionCache } from "./prompt/section-cache.js";
80
79
  export { scanInstructions, combineInstructions } from "./prompt/instruction-scanner.js";
@@ -12,6 +12,7 @@ export { execFileNoThrow } from "./utils/execFileNoThrow.js";
12
12
  export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, resolveGitPath, } from "./utils/exec.js";
13
13
  export { gte } from "./utils/semver.js";
14
14
  export { lock, lockSync, unlock, check } from "./utils/lockfile.js";
15
+ export { acquireFileLock, acquireLockOnPath, mutateJsonFile } from "./utils/file-mutex.js";
15
16
  export { logForDebugging } from "./utils/debug.js";
16
17
  export { isEnvTruthy, isEnvDefinedFalsy, getClaudeConfigHomeDir, isBareMode, parseEnvVars, shouldMaintainProjectWorkingDir, getAWSRegion, getDefaultVertexRegion, getVertexRegionForModel, } from "./utils/envUtils.js";
17
18
  export { startCapturingEarlyInput, stopCapturingEarlyInput, consumeEarlyInput, hasEarlyInput, seedEarlyInput, isCapturingEarlyInput, } from "./utils/earlyInput.js";
@@ -13,6 +13,7 @@ export { execFileNoThrow } from "./utils/execFileNoThrow.js";
13
13
  export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, resolveGitPath, } from "./utils/exec.js";
14
14
  export { gte } from "./utils/semver.js";
15
15
  export { lock, lockSync, unlock, check } from "./utils/lockfile.js";
16
+ export { acquireFileLock, acquireLockOnPath, mutateJsonFile } from "./utils/file-mutex.js";
16
17
  export { logForDebugging } from "./utils/debug.js";
17
18
  export { isEnvTruthy, isEnvDefinedFalsy, getClaudeConfigHomeDir, isBareMode, parseEnvVars, shouldMaintainProjectWorkingDir, getAWSRegion, getDefaultVertexRegion, getVertexRegionForModel, } from "./utils/envUtils.js";
18
19
  export { startCapturingEarlyInput, stopCapturingEarlyInput, consumeEarlyInput, hasEarlyInput, seedEarlyInput, isCapturingEarlyInput, } from "./utils/earlyInput.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.8.9";
6
+ export const VERSION = "0.8.10";
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) ────────────────────────────────────────
@@ -55,7 +55,7 @@ export { Methods, ErrorCodes, } from "./protocol/types.js";
55
55
  // ─── Session ─────────────────────────────────────────────────────
56
56
  export { Transcript } from "./session/transcript.js";
57
57
  export { SessionManager, sessionMainRoot, codeShellHome, sessionsRoot, buildForkState, buildForkTranscript, } from "./session/session-manager.js";
58
- export { FileHistory } from "./session/file-history.js";
58
+ export { FileHistory, } from "./session/file-history.js";
59
59
  export { latestUndoTarget, earliestSnapshotsPerFile, latestTurnUndoTargets, latestRedoTargets, } from "./session/undo-target.js";
60
60
  export { diffLines, renderDiffPreview } from "./session/simple-diff.js";
61
61
  export { MemoryManager } from "./session/memory.js";
@@ -17,6 +17,8 @@ export interface CliLinkRunOptions {
17
17
  signal?: AbortSignal;
18
18
  timeoutMs: number;
19
19
  input?: string;
20
+ /** Keep the pipe alive for browser/device login CLIs that inspect stdin. */
21
+ keepStdinOpen?: boolean;
20
22
  }
21
23
  export type CliLinkCommandRunner = (providerId: CliLinkProviderId, command: string, args: string[], options: CliLinkRunOptions) => Promise<CliLinkCommandResult>;
22
24
  /** App-private CLI location. It never mutates the user's system PATH. */
package/dist/links/cli.js CHANGED
@@ -103,10 +103,15 @@ export const runCliLinkCommand = (providerId, command, args, options) => new Pro
103
103
  }
104
104
  resolve({ stdout: String(stdout), stderr: String(stderr) });
105
105
  });
106
- // All supported flows are deliberately non-interactive (browser/device
107
- // authorization or machine-readable API calls). Always close stdin so a
108
- // CLI cannot mistake the Electron pipe for pending JSON or a hidden prompt.
109
- child.stdin?.end(options.input ?? "");
106
+ if (options.input !== undefined)
107
+ child.stdin?.write(options.input);
108
+ // API calls consume a complete JSON body and must see EOF. Browser/device
109
+ // login is different: gh/glab prompt before opening the browser and some
110
+ // versions of Vercel first present a default login method. Feed one Enter
111
+ // below, but keep the pipe alive so those CLIs do not fail immediately with
112
+ // EOF while the browser authorization is still in progress.
113
+ if (!options.keepStdinOpen)
114
+ child.stdin?.end();
110
115
  });
111
116
  function parseJson(result, providerId) {
112
117
  try {
@@ -289,6 +294,8 @@ export async function connectCliLink(providerId, options, run = runCliLinkComman
289
294
  cwd: options.cwd,
290
295
  signal: options.signal,
291
296
  timeoutMs: 15 * 60_000,
297
+ input: "\n",
298
+ keepStdinOpen: true,
292
299
  });
293
300
  }
294
301
  catch (error) {
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * No remote source this version (design doc §5).
10
10
  */
11
- import { readFileSync, existsSync } from "node:fs";
11
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { userHome } from "../settings/manager.js";
14
14
  import { logger } from "../logging/logger.js";
@@ -16,6 +16,7 @@ import { BUILTIN_CATALOG } from "./builtin.js";
16
16
  import { userCatalogFileSchema } from "./types.js";
17
17
  export { BUILTIN_CATALOG } from "./builtin.js";
18
18
  export { saveCatalogEntry, deleteUserCatalogEntry } from "./save-entry.js";
19
+ const MAX_USER_CATALOG_BYTES = 4 * 1024 * 1024;
19
20
  /** Path to the user catalog file (source B). */
20
21
  export function userCatalogPath() {
21
22
  return join(userHome(), ".code-shell", "model-catalog.user.json");
@@ -26,10 +27,18 @@ export function userCatalogPath() {
26
27
  */
27
28
  export function loadUserCatalog() {
28
29
  const path = userCatalogPath();
29
- if (!existsSync(path))
30
- return [];
30
+ let descriptor;
31
31
  try {
32
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
32
+ const metadata = lstatSync(path);
33
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_USER_CATALOG_BYTES) {
34
+ throw new Error("model catalog must be a bounded regular file");
35
+ }
36
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
37
+ const opened = fstatSync(descriptor);
38
+ if (!opened.isFile() || opened.size > MAX_USER_CATALOG_BYTES) {
39
+ throw new Error("model catalog must be a bounded regular file");
40
+ }
41
+ const parsed = JSON.parse(readFileSync(descriptor, "utf-8"));
33
42
  const result = userCatalogFileSchema.safeParse(parsed);
34
43
  if (!result.success) {
35
44
  logger.warn("model_catalog_user_invalid", { path, issues: result.error.issues.length });
@@ -38,9 +47,15 @@ export function loadUserCatalog() {
38
47
  return result.data;
39
48
  }
40
49
  catch (err) {
50
+ if (err.code === "ENOENT")
51
+ return [];
41
52
  logger.warn("model_catalog_user_read_failed", { path, error: err.message });
42
53
  return [];
43
54
  }
55
+ finally {
56
+ if (descriptor !== undefined)
57
+ closeSync(descriptor);
58
+ }
44
59
  }
45
60
  /**
46
61
  * Merged catalog: built-in (A) ∪ user (B), deduped by `id` with user winning.
@@ -6,10 +6,91 @@
6
6
  * here (those go through the user's own Edit, by design).
7
7
  * See docs/superpowers/specs/2026-06-15-unified-model-catalog-design.md §7.
8
8
  */
9
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from "node:fs";
9
+ import { randomUUID } from "node:crypto";
10
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
10
11
  import { dirname } from "node:path";
11
12
  import { catalogEntrySchema, userCatalogFileSchema } from "./types.js";
12
13
  import { upsertCatalogEntry } from "./upsert.js";
14
+ import { acquireFileLock } from "../utils/file-mutex.js";
15
+ const MAX_USER_CATALOG_BYTES = 4 * 1024 * 1024;
16
+ const BACKUP_STAMP_RE = /^[A-Za-z0-9._-]{1,128}$/;
17
+ function pathEntry(path) {
18
+ try {
19
+ return lstatSync(path);
20
+ }
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return undefined;
24
+ throw error;
25
+ }
26
+ }
27
+ function readExistingCatalog(path) {
28
+ const metadata = pathEntry(path);
29
+ if (!metadata)
30
+ return undefined;
31
+ if (metadata.isSymbolicLink() ||
32
+ !metadata.isFile() ||
33
+ metadata.size > MAX_USER_CATALOG_BYTES) {
34
+ throw new Error("catalog must be a bounded regular file");
35
+ }
36
+ const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
37
+ try {
38
+ const opened = fstatSync(descriptor);
39
+ if (!opened.isFile() || opened.size > MAX_USER_CATALOG_BYTES) {
40
+ throw new Error("catalog must be a bounded regular file");
41
+ }
42
+ const raw = readFileSync(descriptor, "utf8");
43
+ try {
44
+ const parsed = userCatalogFileSchema.safeParse(JSON.parse(raw));
45
+ return { raw, entries: parsed.success ? parsed.data : [] };
46
+ }
47
+ catch {
48
+ return { raw, entries: [] };
49
+ }
50
+ }
51
+ finally {
52
+ closeSync(descriptor);
53
+ }
54
+ }
55
+ function writeBackup(path, raw) {
56
+ try {
57
+ writeFileSync(path, raw, { encoding: "utf8", mode: 0o600, flag: "wx" });
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ function writeCatalogAtomic(path, entries) {
65
+ const serialized = `${JSON.stringify(userCatalogFileSchema.parse(entries), null, 2)}\n`;
66
+ if (Buffer.byteLength(serialized, "utf8") > MAX_USER_CATALOG_BYTES) {
67
+ throw new Error("catalog exceeds its size limit");
68
+ }
69
+ const parent = dirname(path);
70
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
71
+ const parentInfo = lstatSync(parent);
72
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
73
+ throw new Error("catalog parent must be a real directory");
74
+ }
75
+ const target = pathEntry(path);
76
+ if (target && (target.isSymbolicLink() || !target.isFile())) {
77
+ throw new Error("catalog target must be a regular file");
78
+ }
79
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
80
+ try {
81
+ writeFileSync(temporary, serialized, {
82
+ encoding: "utf8",
83
+ mode: 0o600,
84
+ flag: "wx",
85
+ });
86
+ renameSync(temporary, path);
87
+ if (process.platform !== "win32")
88
+ chmodSync(path, 0o600);
89
+ }
90
+ finally {
91
+ rmSync(temporary, { force: true });
92
+ }
93
+ }
13
94
  /**
14
95
  * @param stamp caller-supplied unique suffix for the backup filename. Pass a
15
96
  * timestamp/counter from the caller — core forbids Date.now() in some paths
@@ -22,47 +103,27 @@ export function saveCatalogEntry(entry, opts) {
22
103
  return { ok: false, error: `invalid catalog entry: ${parsed.error.issues.map((i) => i.message).join("; ")}` };
23
104
  }
24
105
  const valid = parsed.data;
25
- // Read + back up any existing file (even if corrupt — never silently lose it).
26
- let current = [];
27
- let backup;
28
- if (existsSync(opts.path)) {
29
- backup = `${opts.path}.bak-${opts.stamp}`;
30
- try {
31
- copyFileSync(opts.path, backup);
32
- }
33
- catch {
34
- backup = undefined; // best-effort; don't abort the write on backup failure
35
- }
36
- try {
37
- const raw = JSON.parse(readFileSync(opts.path, "utf-8"));
38
- const safe = userCatalogFileSchema.safeParse(raw);
39
- current = safe.success ? safe.data : [];
40
- }
41
- catch {
42
- current = []; // corrupt → start fresh (original preserved in backup)
43
- }
44
- }
45
- const action = current.some((e) => e.id === valid.id) ? "updated" : "added";
46
- const next = upsertCatalogEntry(current, valid);
47
- // Ensure the parent dir exists — a first-ever catalog write on a machine
48
- // whose ~/.code-shell hasn't been created yet would otherwise throw ENOENT
49
- // (the agent-facing tool wants a clean {ok:false} or a real write, not a crash).
106
+ if (!BACKUP_STAMP_RE.test(opts.stamp))
107
+ return { ok: false, error: "invalid backup stamp" };
108
+ let release;
50
109
  try {
51
- mkdirSync(dirname(opts.path), { recursive: true });
110
+ release = acquireFileLock(opts.path);
111
+ const existing = readExistingCatalog(opts.path);
112
+ let backup = existing ? `${opts.path}.bak-${opts.stamp}` : undefined;
113
+ if (existing && !writeBackup(backup, existing.raw))
114
+ backup = undefined;
115
+ const action = existing?.entries.some((e) => e.id === valid.id)
116
+ ? "updated"
117
+ : "added";
118
+ writeCatalogAtomic(opts.path, upsertCatalogEntry(existing?.entries ?? [], valid));
119
+ return { ok: true, action, backup };
52
120
  }
53
121
  catch (e) {
54
- return { ok: false, error: `could not create catalog directory: ${e instanceof Error ? e.message : String(e)}`, backup };
122
+ return { ok: false, error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}` };
55
123
  }
56
- try {
57
- writeFileSync(opts.path, JSON.stringify(next, null, 2));
58
- }
59
- catch (e) {
60
- // IO error (perms / disk full / bad path): return a clean {ok:false} with the
61
- // backup filename preserved, never let the throw escape past the tool's
62
- // expected result shape (the original file is intact — we only upsert-wrote).
63
- return { ok: false, error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}`, backup };
124
+ finally {
125
+ release?.();
64
126
  }
65
- return { ok: true, action, backup };
66
127
  }
67
128
  /**
68
129
  * Remove the entry with `id` from the user catalog file. Mirrors saveCatalogEntry's
@@ -72,33 +133,33 @@ export function saveCatalogEntry(entry, opts) {
72
133
  * built-in version ("reset" semantics).
73
134
  */
74
135
  export function deleteUserCatalogEntry(id, opts) {
75
- if (!existsSync(opts.path))
76
- return { ok: true, removed: false };
77
- let backup = `${opts.path}.bak-${opts.stamp}`;
78
- try {
79
- copyFileSync(opts.path, backup);
80
- }
81
- catch {
82
- backup = undefined;
136
+ if (!BACKUP_STAMP_RE.test(opts.stamp)) {
137
+ return { ok: false, removed: false, error: "invalid backup stamp" };
83
138
  }
84
- let current = [];
139
+ let release;
85
140
  try {
86
- const raw = JSON.parse(readFileSync(opts.path, "utf-8"));
87
- const safe = userCatalogFileSchema.safeParse(raw);
88
- current = safe.success ? safe.data : [];
89
- }
90
- catch {
91
- current = [];
92
- }
93
- const next = current.filter((e) => e.id !== id);
94
- const removed = next.length !== current.length;
95
- if (!removed)
96
- return { ok: true, removed: false, backup };
97
- try {
98
- writeFileSync(opts.path, JSON.stringify(next, null, 2));
141
+ release = acquireFileLock(opts.path);
142
+ const existing = readExistingCatalog(opts.path);
143
+ if (!existing)
144
+ return { ok: true, removed: false };
145
+ let backup = `${opts.path}.bak-${opts.stamp}`;
146
+ if (!writeBackup(backup, existing.raw))
147
+ backup = undefined;
148
+ const next = existing.entries.filter((e) => e.id !== id);
149
+ const removed = next.length !== existing.entries.length;
150
+ if (!removed)
151
+ return { ok: true, removed: false, backup };
152
+ writeCatalogAtomic(opts.path, next);
153
+ return { ok: true, removed: true, backup };
99
154
  }
100
155
  catch (e) {
101
- return { ok: false, removed: false, error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}`, backup };
156
+ return {
157
+ ok: false,
158
+ removed: false,
159
+ error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}`,
160
+ };
161
+ }
162
+ finally {
163
+ release?.();
102
164
  }
103
- return { ok: true, removed: true, backup };
104
165
  }