@cjhyy/code-shell-core 0.8.8 → 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 (86) 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 +81 -27
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-finalize.js +8 -6
  10. package/dist/engine/run-session-open.js +1 -1
  11. package/dist/engine/run-setup.d.ts +4 -0
  12. package/dist/engine/run-setup.js +5 -1
  13. package/dist/engine/run-tooling.js +7 -1
  14. package/dist/engine/run-types.d.ts +33 -0
  15. package/dist/engine/run-types.js +21 -0
  16. package/dist/engine/run-workspace.js +11 -5
  17. package/dist/engine/turn-loop.js +9 -8
  18. package/dist/goal/lifecycle.d.ts +2 -0
  19. package/dist/goal/lifecycle.js +56 -33
  20. package/dist/index.d.ts +2 -3
  21. package/dist/index.internal.d.ts +1 -0
  22. package/dist/index.internal.js +1 -0
  23. package/dist/index.js +2 -2
  24. package/dist/links/cli.d.ts +2 -0
  25. package/dist/links/cli.js +11 -4
  26. package/dist/model-catalog/index.js +19 -4
  27. package/dist/model-catalog/save-entry.js +122 -61
  28. package/dist/model-catalog/types.js +27 -23
  29. package/dist/panel-apps/installer.js +27 -14
  30. package/dist/panel-apps/manifest.d.ts +11 -11
  31. package/dist/panel-apps/manifest.js +3 -1
  32. package/dist/panel-apps/registry.js +60 -12
  33. package/dist/plugins/installedPlugins.d.ts +4 -0
  34. package/dist/plugins/installedPlugins.js +70 -30
  35. package/dist/plugins/installer/types.d.ts +12 -12
  36. package/dist/plugins/installer/update.js +37 -38
  37. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  38. package/dist/plugins/knownMarketplaces.js +127 -23
  39. package/dist/plugins/pluginCatalog.js +18 -4
  40. package/dist/plugins/pluginHookApproval.js +56 -60
  41. package/dist/plugins/pluginMcpApproval.js +50 -52
  42. package/dist/profile/catalog-store.js +39 -4
  43. package/dist/profile/catalog.js +55 -15
  44. package/dist/profile/store.js +51 -21
  45. package/dist/prompt/composer.d.ts +8 -0
  46. package/dist/prompt/composer.js +8 -0
  47. package/dist/protocol/chat-session-manager.d.ts +9 -0
  48. package/dist/protocol/chat-session-manager.js +22 -0
  49. package/dist/protocol/chat-session.d.ts +5 -0
  50. package/dist/protocol/chat-session.js +1 -0
  51. package/dist/protocol/server.d.ts +2 -0
  52. package/dist/protocol/server.js +112 -29
  53. package/dist/protocol/types.d.ts +20 -0
  54. package/dist/run/FileRunStore.d.ts +2 -0
  55. package/dist/run/FileRunStore.js +153 -18
  56. package/dist/run/Heartbeat.js +63 -4
  57. package/dist/services/auto-dream.js +39 -17
  58. package/dist/services/session-memory.js +107 -8
  59. package/dist/session/file-history.d.ts +63 -2
  60. package/dist/session/file-history.js +593 -86
  61. package/dist/session/session-manager.d.ts +4 -1
  62. package/dist/session/session-manager.js +94 -47
  63. package/dist/session/transcript.js +33 -3
  64. package/dist/session/undo-target.d.ts +15 -6
  65. package/dist/session/undo-target.js +26 -9
  66. package/dist/settings/manager.d.ts +22 -3
  67. package/dist/settings/manager.js +185 -50
  68. package/dist/settings/schema.d.ts +3 -3
  69. package/dist/sources/adapters/local-files.js +49 -4
  70. package/dist/sources/catalog.js +64 -18
  71. package/dist/sources/types.d.ts +3 -3
  72. package/dist/sources/types.js +7 -4
  73. package/dist/themes/installer.js +192 -28
  74. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  75. package/dist/tool-system/builtin/cron.d.ts +2 -1
  76. package/dist/tool-system/builtin/cron.js +20 -6
  77. package/dist/tool-system/builtin/index.js +48 -1
  78. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  79. package/dist/tool-system/builtin/install-capability.js +1057 -0
  80. package/dist/tool-system/builtin/skill.js +3 -1
  81. package/dist/tool-system/executor.js +1 -0
  82. package/dist/tool-system/path-policy.js +25 -33
  83. package/dist/tool-system/registry.js +5 -0
  84. package/dist/utils/file-mutex.d.ts +2 -0
  85. package/dist/utils/file-mutex.js +29 -4
  86. package/package.json +2 -1
@@ -108,7 +108,7 @@ export declare class SessionManager {
108
108
  * Create a session. `qchat-` sessions stay process-local; ordinary sessions
109
109
  * materialize state.json + transcript.jsonl before return.
110
110
  */
111
- create(cwd: string, model: string, provider: string, explicitSessionId?: string, parentSessionId?: string | null, origin?: import("../types.js").SessionOrigin, kind?: SessionKind): SessionBundle;
111
+ create(cwd: string, model: string, provider: string, explicitSessionId?: string, parentSessionId?: string | null, origin?: import("../types.js").SessionOrigin, kind?: SessionKind, ephemeral?: boolean): SessionBundle;
112
112
  /** Whether a persisted or process-local session exists. */
113
113
  exists(sessionId: string): boolean;
114
114
  /**
@@ -236,6 +236,8 @@ export declare class SessionManager {
236
236
  * workspace/profile metadata.
237
237
  */
238
238
  readSessionState(sessionId: string): SessionState | undefined;
239
+ /** Whether a live or persisted Session is explicitly process-local. */
240
+ isEphemeralSession(sessionId: string): boolean;
239
241
  /**
240
242
  * Merge a field-level state update into the latest persisted snapshot.
241
243
  *
@@ -288,6 +290,7 @@ export declare class SessionManager {
288
290
  */
289
291
  saveStateOrUpdateFields(state: SessionState, partial: SessionStateFieldPatch): boolean;
290
292
  private saveStateAttempt;
293
+ private writeStateAtomically;
291
294
  private acquireStateLock;
292
295
  /** Recover only the old implementation's regular-file orphan lock. */
293
296
  private prepareLegacyStateLock;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Session lifecycle manager.
3
3
  */
4
- import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
+ import { closeSync, chmodSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { createHash } from "node:crypto";
@@ -148,26 +148,34 @@ function adoptCompatibilityGoalMutation(state) {
148
148
  // disk. The storage root remains part of the key to preserve identity/data-root
149
149
  // isolation.
150
150
  const processLocalSessionBundles = new Map();
151
- const FORK_COPY_EVENT_TYPES = new Set([
152
- "message",
153
- "tool_use",
154
- "tool_result",
155
- "summary",
156
- "context_transfer",
157
- "range_archive",
158
- "content_replace",
159
- "subagent",
160
- "external_file_changes",
161
- "goal_progress",
162
- "turn_boundary",
163
- "turn_stopped",
164
- "error",
165
- ]);
166
- const FORK_SKIP_EVENT_TYPES = new Set([
167
- "session_meta",
168
- "file_history",
169
- "plan_operation",
170
- ]);
151
+ /**
152
+ * Every known transcript event must make an explicit fork decision. The
153
+ * `satisfies` constraint turns new TranscriptEventType additions into a
154
+ * compile-time error instead of a quick-chat failure discovered at runtime.
155
+ * The runtime fallback below still rejects unknown events from newer or
156
+ * malformed persisted transcripts.
157
+ */
158
+ const FORK_EVENT_POLICY = {
159
+ message: "copy",
160
+ tool_use: "copy",
161
+ tool_result: "copy",
162
+ summary: "copy",
163
+ context_transfer: "copy",
164
+ range_archive: "copy",
165
+ content_replace: "copy",
166
+ file_history: "skip",
167
+ plan_operation: "skip",
168
+ session_meta: "skip",
169
+ subagent: "copy",
170
+ external_file_changes: "copy",
171
+ turn_boundary: "copy",
172
+ // Idempotency receipts belong to the source session and never contribute to
173
+ // model context. Copying them could replay a source response in the child.
174
+ run_result: "skip",
175
+ goal_progress: "copy",
176
+ turn_stopped: "copy",
177
+ error: "copy",
178
+ };
171
179
  const FORK_STAGING_NAME = /^\.pending-fork-[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}$/;
172
180
  const FORK_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
173
181
  const FORK_STAGING_CLEANUP_LIMIT = 32;
@@ -296,7 +304,9 @@ export class SessionManager {
296
304
  resolveCapabilities()
297
305
  .map((capability) => capability.sessionWorkspace)
298
306
  .find((candidate) => candidate !== undefined);
299
- mkdirSync(this.sessionsDir, { recursive: true });
307
+ mkdirSync(this.sessionsDir, { recursive: true, mode: 0o700 });
308
+ if (process.platform !== "win32")
309
+ chmodSync(this.sessionsDir, 0o700);
300
310
  this.cleanupStaleForkStaging();
301
311
  }
302
312
  processLocalKey(sessionId) {
@@ -366,7 +376,7 @@ export class SessionManager {
366
376
  * Create a session. `qchat-` sessions stay process-local; ordinary sessions
367
377
  * materialize state.json + transcript.jsonl before return.
368
378
  */
369
- create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
379
+ create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work", ephemeral = false) {
370
380
  // External callers may pass any string; nanoid output is trusted. Either
371
381
  // way the ID gets joined into a filesystem path, so the public entry
372
382
  // point validates before that join.
@@ -394,7 +404,7 @@ export class SessionManager {
394
404
  // new top-level session (key present, null) apart from a legacy session
395
405
  // (key absent) and from a sub-agent (key present, non-empty string).
396
406
  parentSessionId: parentSessionId ?? null,
397
- ...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
407
+ ...(ephemeral || sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
398
408
  ...(origin ? { origin } : {}),
399
409
  };
400
410
  if (isEphemeralSessionState(state)) {
@@ -410,13 +420,16 @@ export class SessionManager {
410
420
  startedAt: state.startedAt,
411
421
  kind,
412
422
  });
423
+ if (transcript.flushFailed()) {
424
+ throw new SessionError(`Failed to persist initial transcript for ${sessionId}`);
425
+ }
413
426
  const bundle = { state, transcript };
414
427
  this.storeProcessLocalBundle(bundle);
415
428
  return bundle;
416
429
  }
417
430
  const sessionDir = join(this.sessionsDir, sessionId);
418
431
  try {
419
- mkdirSync(sessionDir);
432
+ mkdirSync(sessionDir, { mode: 0o700 });
420
433
  }
421
434
  catch (err) {
422
435
  if (err.code === "EEXIST") {
@@ -426,20 +439,26 @@ export class SessionManager {
426
439
  }
427
440
  // Atomic write (tmp+rename) like saveState, so a crash during this one-time
428
441
  // create can't leave a torn state.json that resume() then fails to parse.
429
- const stateTarget = join(sessionDir, "state.json");
430
- const stateTmp = `${stateTarget}.${process.pid}.${Date.now()}.create.tmp`;
431
- writeFileSync(stateTmp, JSON.stringify(state, null, 2), "utf-8");
432
- renameSync(stateTmp, stateTarget);
433
- const transcript = new Transcript(join(sessionDir, "transcript.jsonl"));
434
- transcript.append("session_meta", {
435
- sessionId,
436
- cwd,
437
- model,
438
- provider,
439
- startedAt: state.startedAt,
440
- kind,
441
- });
442
- return { state, transcript };
442
+ try {
443
+ const stateTarget = join(sessionDir, "state.json");
444
+ this.writeStateAtomically(stateTarget, state, "create");
445
+ const transcript = new Transcript(join(sessionDir, "transcript.jsonl"));
446
+ transcript.append("session_meta", {
447
+ sessionId,
448
+ cwd,
449
+ model,
450
+ provider,
451
+ startedAt: state.startedAt,
452
+ kind,
453
+ });
454
+ return { state, transcript };
455
+ }
456
+ catch (error) {
457
+ // A failed first materialization must not reserve the session id forever
458
+ // with an empty/partial directory.
459
+ rmSync(sessionDir, { recursive: true, force: true });
460
+ throw error;
461
+ }
443
462
  }
444
463
  /** Whether a persisted or process-local session exists. */
445
464
  exists(sessionId) {
@@ -1017,6 +1036,10 @@ export class SessionManager {
1017
1036
  throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
1018
1037
  }
1019
1038
  const transcriptFile = join(sessionDir, "transcript.jsonl");
1039
+ if (process.platform !== "win32") {
1040
+ chmodSync(sessionDir, 0o700);
1041
+ chmodSync(stateFile, 0o600);
1042
+ }
1020
1043
  const transcript = Transcript.loadFromFile(transcriptFile);
1021
1044
  state.kind = normalizedSessionKind(state.kind);
1022
1045
  state.status = "active";
@@ -1049,6 +1072,10 @@ export class SessionManager {
1049
1072
  return undefined;
1050
1073
  }
1051
1074
  }
1075
+ /** Whether a live or persisted Session is explicitly process-local. */
1076
+ isEphemeralSession(sessionId) {
1077
+ return this.readSessionState(sessionId)?.ephemeral === true;
1078
+ }
1052
1079
  /**
1053
1080
  * Merge a field-level state update into the latest persisted snapshot.
1054
1081
  *
@@ -1357,9 +1384,7 @@ export class SessionManager {
1357
1384
  state.title = persisted.title;
1358
1385
  }
1359
1386
  state.stateRevision = (persistedRevision ?? incomingRevision ?? 0) + 1;
1360
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
1361
- writeFileSync(tmp, JSON.stringify(stateForPersistence(state), null, 2), "utf-8");
1362
- renameSync(tmp, target);
1387
+ this.writeStateAtomically(target, stateForPersistence(state));
1363
1388
  return { ok: true };
1364
1389
  }
1365
1390
  finally {
@@ -1371,6 +1396,24 @@ export class SessionManager {
1371
1396
  }
1372
1397
  }
1373
1398
  }
1399
+ writeStateAtomically(target, state, label = "state") {
1400
+ const tmp = `${target}.${process.pid}.${nanoid(8)}.${label}.tmp`;
1401
+ try {
1402
+ writeFileSync(tmp, JSON.stringify(state, null, 2), {
1403
+ encoding: "utf-8",
1404
+ mode: 0o600,
1405
+ });
1406
+ renameSync(tmp, target);
1407
+ }
1408
+ finally {
1409
+ try {
1410
+ rmSync(tmp, { force: true });
1411
+ }
1412
+ catch {
1413
+ // Preserve the original write/rename error; cleanup is best effort.
1414
+ }
1415
+ }
1416
+ }
1374
1417
  acquireStateLock(target) {
1375
1418
  const lockPath = `${target}.lock`;
1376
1419
  for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
@@ -1569,7 +1612,10 @@ export class SessionManager {
1569
1612
  return this.freezeForkSnapshot(sourceSessionId, sourceState, parsed.events, throughEventId, snapshotMode);
1570
1613
  }
1571
1614
  freezeForkSnapshot(sourceSessionId, sourceState, events, throughEventId, snapshotMode) {
1572
- const sourceEvents = structuredClone([...events]);
1615
+ // Both process-local and disk readers provide a snapshot array. Fork
1616
+ // selection is synchronous, so keep event references here and clone once
1617
+ // when the independently-owned target transcript is constructed below.
1618
+ const sourceEvents = [...events];
1573
1619
  let frozen = sourceEvents;
1574
1620
  const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
1575
1621
  if (snapshotMode === "completed" && effectiveCursor === undefined) {
@@ -1600,12 +1646,13 @@ export class SessionManager {
1600
1646
  }
1601
1647
  const copiedEvents = [];
1602
1648
  for (const event of frozen) {
1603
- if (FORK_SKIP_EVENT_TYPES.has(event.type))
1649
+ const policy = FORK_EVENT_POLICY[event.type];
1650
+ if (policy === "skip")
1604
1651
  continue;
1605
- if (!FORK_COPY_EVENT_TYPES.has(event.type)) {
1652
+ if (policy !== "copy") {
1606
1653
  throw new SessionError(`Unsupported transcript event in fork: ${String(event.type)}`);
1607
1654
  }
1608
- copiedEvents.push(structuredClone(event));
1655
+ copiedEvents.push(event);
1609
1656
  }
1610
1657
  validateForkToolPairs(copiedEvents);
1611
1658
  return { sourceState: structuredClone(sourceState), copiedEvents };
@@ -1628,7 +1675,7 @@ export class SessionManager {
1628
1675
  const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
1629
1676
  let published = false;
1630
1677
  try {
1631
- mkdirSync(stagingDir);
1678
+ mkdirSync(stagingDir, { mode: 0o700 });
1632
1679
  writeFileSync(join(stagingDir, "state.json"), JSON.stringify(state, null, 2), {
1633
1680
  encoding: "utf-8",
1634
1681
  mode: 0o600,
@@ -2,10 +2,36 @@
2
2
  * Transcript — JSONL event log (NOT chat history).
3
3
  * toMessages() derives Message[] from events for sending to LLM.
4
4
  */
5
- import { appendFileSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync, } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  import { nanoid } from "nanoid";
8
8
  import { logger } from "../logging/logger.js";
9
+ function appendTranscriptLine(filePath, data) {
10
+ // Use one append-mode descriptor so concurrent OS writers cannot overwrite
11
+ // one another. Also repair the record boundary after a crash-torn final line;
12
+ // otherwise the next valid event is concatenated to the fragment and both
13
+ // are discarded on replay.
14
+ const fd = openSync(filePath, "a+", 0o600);
15
+ try {
16
+ if (process.platform !== "win32")
17
+ fchmodSync(fd, 0o600);
18
+ const size = fstatSync(fd).size;
19
+ let prefix = "";
20
+ if (size > 0) {
21
+ const lastByte = Buffer.allocUnsafe(1);
22
+ readSync(fd, lastByte, 0, 1, size - 1);
23
+ if (lastByte[0] !== 0x0a)
24
+ prefix = "\n";
25
+ }
26
+ appendFileSync(fd, prefix + data, "utf-8");
27
+ }
28
+ finally {
29
+ closeSync(fd);
30
+ }
31
+ }
32
+ const defaultTranscriptWriter = (filePath, data) => {
33
+ appendTranscriptLine(filePath, data);
34
+ };
9
35
  const CONTEXT_EVENT_TYPES = new Set([
10
36
  "message",
11
37
  "tool_use",
@@ -68,7 +94,7 @@ export class Transcript {
68
94
  getFilePath() {
69
95
  return this.filePath;
70
96
  }
71
- constructor(filePath, writer = appendFileSync, options = {}) {
97
+ constructor(filePath, writer = defaultTranscriptWriter, options = {}) {
72
98
  this.filePath = filePath;
73
99
  this.writer = writer;
74
100
  this.persistent = options.persistent !== false;
@@ -76,7 +102,11 @@ export class Transcript {
76
102
  return;
77
103
  mkdirSync(dirname(filePath), { recursive: true });
78
104
  if (!existsSync(filePath)) {
79
- writeFileSync(filePath, "", "utf-8");
105
+ writeFileSync(filePath, "", { encoding: "utf-8", mode: 0o600 });
106
+ }
107
+ else if (process.platform !== "win32") {
108
+ // Tighten transcripts created by older releases on first use.
109
+ chmodSync(filePath, 0o600);
80
110
  }
81
111
  }
82
112
  /** A process-local transcript that never creates or appends a file. */
@@ -55,12 +55,21 @@ export declare function latestTurnUndoTargets(snapshots: FileSnapshot[]): FileSn
55
55
  * `redoLatestTurn` should re-apply — but ONLY when that turn is still the latest
56
56
  * undone state (i.e. nothing newer happened since the undo). Otherwise [].
57
57
  *
58
- * Rule: let R = the greatest turnSeq present in `redoRecords` (a RedoRecord
59
- * exists only for a turn that was undone and not yet redone). R is redoable iff
60
- * NO snapshot belongs to a strictly newer LIVE (non-undone) turn a fresh edit
61
- * after the undo supersedes the redo and invalidates it (spec: "新轮使 redo
62
- * 失效"). Created-only turns have no pre-turn snapshot, so the RedoRecord itself
63
- * is the sole evidence and is honoured as long as nothing newer superseded it.
58
+ * Rule: let R = the LOWEST turnSeq present in `redoRecords` (a RedoRecord exists
59
+ * only for a turn that was undone and not yet redone). Undo walks BACKWARD down
60
+ * the turn stack, so after `undo(2); undo(1)` both records are pending and the
61
+ * one to re-apply first is turn 1 — the most recently undone, i.e. the lowest.
62
+ * Selecting the greatest instead replayed turns out of order: `redo` jumped
63
+ * straight to turn 2, skipping turn 1's state entirely and stranding turn 1's
64
+ * record forever (its snapshots stay `undone`, so it never becomes selectable).
65
+ * With two files that also produced a state that never existed — turn 1 still
66
+ * reverted while turn 2 was applied.
67
+ *
68
+ * R is redoable iff NO snapshot belongs to a strictly newer LIVE (non-undone)
69
+ * turn — a fresh edit after the undo supersedes the redo and invalidates it
70
+ * (spec: "新轮使 redo 失效"). Created-only turns have no pre-turn snapshot, so
71
+ * the RedoRecord itself is the sole evidence and is honoured as long as nothing
72
+ * newer superseded it.
64
73
  *
65
74
  * Empty `redoRecords` → []. fs-free so /redo's target decision is unit-testable.
66
75
  */
@@ -114,25 +114,42 @@ export function latestTurnUndoTargets(snapshots) {
114
114
  * `redoLatestTurn` should re-apply — but ONLY when that turn is still the latest
115
115
  * undone state (i.e. nothing newer happened since the undo). Otherwise [].
116
116
  *
117
- * Rule: let R = the greatest turnSeq present in `redoRecords` (a RedoRecord
118
- * exists only for a turn that was undone and not yet redone). R is redoable iff
119
- * NO snapshot belongs to a strictly newer LIVE (non-undone) turn a fresh edit
120
- * after the undo supersedes the redo and invalidates it (spec: "新轮使 redo
121
- * 失效"). Created-only turns have no pre-turn snapshot, so the RedoRecord itself
122
- * is the sole evidence and is honoured as long as nothing newer superseded it.
117
+ * Rule: let R = the LOWEST turnSeq present in `redoRecords` (a RedoRecord exists
118
+ * only for a turn that was undone and not yet redone). Undo walks BACKWARD down
119
+ * the turn stack, so after `undo(2); undo(1)` both records are pending and the
120
+ * one to re-apply first is turn 1 — the most recently undone, i.e. the lowest.
121
+ * Selecting the greatest instead replayed turns out of order: `redo` jumped
122
+ * straight to turn 2, skipping turn 1's state entirely and stranding turn 1's
123
+ * record forever (its snapshots stay `undone`, so it never becomes selectable).
124
+ * With two files that also produced a state that never existed — turn 1 still
125
+ * reverted while turn 2 was applied.
126
+ *
127
+ * R is redoable iff NO snapshot belongs to a strictly newer LIVE (non-undone)
128
+ * turn — a fresh edit after the undo supersedes the redo and invalidates it
129
+ * (spec: "新轮使 redo 失效"). Created-only turns have no pre-turn snapshot, so
130
+ * the RedoRecord itself is the sole evidence and is honoured as long as nothing
131
+ * newer superseded it.
123
132
  *
124
133
  * Empty `redoRecords` → []. fs-free so /redo's target decision is unit-testable.
125
134
  */
126
135
  export function latestRedoTargets(redoRecords, snapshots) {
127
136
  if (redoRecords.length === 0)
128
137
  return [];
138
+ // The most recently undone turn is the LOWEST pending one (undo pops the
139
+ // stack from the top down), and it is the next one to re-apply.
140
+ let nextRedoTurn = Infinity;
141
+ for (const r of redoRecords) {
142
+ if (r.turnSeq < nextRedoTurn)
143
+ nextRedoTurn = r.turnSeq;
144
+ }
145
+ // Supersession is still judged against the NEWEST pending redo turn: a live
146
+ // snapshot newer than that is a fresh edit made after the undo, which
147
+ // invalidates the whole pending redo chain rather than just one turn.
129
148
  let maxRedoTurn = -Infinity;
130
149
  for (const r of redoRecords) {
131
150
  if (r.turnSeq > maxRedoTurn)
132
151
  maxRedoTurn = r.turnSeq;
133
152
  }
134
- // If any LIVE snapshot belongs to a strictly newer turn, the redo turn is no
135
- // longer the latest undone state → not redoable.
136
153
  for (const s of snapshots) {
137
154
  if (s.undone)
138
155
  continue;
@@ -140,5 +157,5 @@ export function latestRedoTargets(redoRecords, snapshots) {
140
157
  if (k > maxRedoTurn)
141
158
  return [];
142
159
  }
143
- return redoRecords.filter((r) => r.turnSeq === maxRedoTurn);
160
+ return redoRecords.filter((r) => r.turnSeq === nextRedoTurn);
144
161
  }
@@ -145,24 +145,43 @@ export declare class SettingsManager {
145
145
  * cache invalidation mirror saveUserSetting.
146
146
  */
147
147
  saveProjectSetting(key: string, value: unknown, cwd: string): void;
148
+ /**
149
+ * Persist a machine-private setting for one project. The local layer has
150
+ * higher precedence than the shared project layer and lives at
151
+ * `${cwd}/.code-shell/settings.local.json`, matching the file already read
152
+ * by {@link load}. It is useful for MCP endpoints or policy that should not
153
+ * be shared with collaborators.
154
+ */
155
+ saveLocalSetting(key: string, value: unknown, cwd: string): void;
148
156
  /**
149
157
  * Delete a single dotted key from the PROJECT-level config file. Used to
150
158
  * express "inherit" — we don't persist the literal "inherit"; we remove the
151
159
  * override key. No-ops if the file or any intermediate segment is absent.
152
160
  */
153
161
  deleteProjectSetting(key: string, cwd: string): void;
162
+ /** Delete one dotted key from the machine-private project settings layer. */
163
+ deleteLocalSetting(key: string, cwd: string): void;
164
+ /** Delete one dotted key from the user settings layer. */
165
+ deleteUserSetting(key: string): void;
166
+ private deleteSettingFromFile;
154
167
  /**
155
168
  * Read ONE scope's raw settings file, validated but UNMERGED. Capability
156
169
  * overlay math needs the project overlay and the user/global baseline
157
170
  * separately — the merged get() collapses provenance and can't express
158
171
  * tri-state inheritance. user → ~/.code-shell/settings.json, project →
159
- * ${cwd}/.code-shell/settings.json. Only keys actually present in the file
160
- * are returned (defaults are not synthesized), so an absent file → {}.
172
+ * ${cwd}/.code-shell/settings.json, local
173
+ * ${cwd}/.code-shell/settings.local.json. Only keys actually present in the
174
+ * file are returned (defaults are not synthesized), so an absent file → {}.
161
175
  */
162
- getForScope(scope: "user" | "project", cwd?: string): Partial<ValidatedSettings>;
176
+ getForScope(scope: "user" | "project" | "local", cwd?: string): Partial<ValidatedSettings>;
163
177
  private projectSettingsPath;
178
+ private localSettingsPath;
179
+ private validateProjectCwd;
180
+ /** Resolve the project root once and refuse a linked/non-directory state root. */
181
+ private tryProjectSettingsPath;
164
182
  private readJsonObject;
165
183
  private atomicWriteJson;
184
+ private writeBackup;
166
185
  /**
167
186
  * Read-modify-write a settings file under a cross-process lock.
168
187
  *