@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
@@ -290,6 +290,7 @@ export declare class SessionManager {
290
290
  */
291
291
  saveStateOrUpdateFields(state: SessionState, partial: SessionStateFieldPatch): boolean;
292
292
  private saveStateAttempt;
293
+ private writeStateAtomically;
293
294
  private acquireStateLock;
294
295
  /** Recover only the old implementation's regular-file orphan lock. */
295
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";
@@ -304,7 +304,9 @@ export class SessionManager {
304
304
  resolveCapabilities()
305
305
  .map((capability) => capability.sessionWorkspace)
306
306
  .find((candidate) => candidate !== undefined);
307
- mkdirSync(this.sessionsDir, { recursive: true });
307
+ mkdirSync(this.sessionsDir, { recursive: true, mode: 0o700 });
308
+ if (process.platform !== "win32")
309
+ chmodSync(this.sessionsDir, 0o700);
308
310
  this.cleanupStaleForkStaging();
309
311
  }
310
312
  processLocalKey(sessionId) {
@@ -418,13 +420,16 @@ export class SessionManager {
418
420
  startedAt: state.startedAt,
419
421
  kind,
420
422
  });
423
+ if (transcript.flushFailed()) {
424
+ throw new SessionError(`Failed to persist initial transcript for ${sessionId}`);
425
+ }
421
426
  const bundle = { state, transcript };
422
427
  this.storeProcessLocalBundle(bundle);
423
428
  return bundle;
424
429
  }
425
430
  const sessionDir = join(this.sessionsDir, sessionId);
426
431
  try {
427
- mkdirSync(sessionDir);
432
+ mkdirSync(sessionDir, { mode: 0o700 });
428
433
  }
429
434
  catch (err) {
430
435
  if (err.code === "EEXIST") {
@@ -434,20 +439,26 @@ export class SessionManager {
434
439
  }
435
440
  // Atomic write (tmp+rename) like saveState, so a crash during this one-time
436
441
  // create can't leave a torn state.json that resume() then fails to parse.
437
- const stateTarget = join(sessionDir, "state.json");
438
- const stateTmp = `${stateTarget}.${process.pid}.${Date.now()}.create.tmp`;
439
- writeFileSync(stateTmp, JSON.stringify(state, null, 2), "utf-8");
440
- renameSync(stateTmp, stateTarget);
441
- const transcript = new Transcript(join(sessionDir, "transcript.jsonl"));
442
- transcript.append("session_meta", {
443
- sessionId,
444
- cwd,
445
- model,
446
- provider,
447
- startedAt: state.startedAt,
448
- kind,
449
- });
450
- 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
+ }
451
462
  }
452
463
  /** Whether a persisted or process-local session exists. */
453
464
  exists(sessionId) {
@@ -1025,6 +1036,10 @@ export class SessionManager {
1025
1036
  throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
1026
1037
  }
1027
1038
  const transcriptFile = join(sessionDir, "transcript.jsonl");
1039
+ if (process.platform !== "win32") {
1040
+ chmodSync(sessionDir, 0o700);
1041
+ chmodSync(stateFile, 0o600);
1042
+ }
1028
1043
  const transcript = Transcript.loadFromFile(transcriptFile);
1029
1044
  state.kind = normalizedSessionKind(state.kind);
1030
1045
  state.status = "active";
@@ -1369,9 +1384,7 @@ export class SessionManager {
1369
1384
  state.title = persisted.title;
1370
1385
  }
1371
1386
  state.stateRevision = (persistedRevision ?? incomingRevision ?? 0) + 1;
1372
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
1373
- writeFileSync(tmp, JSON.stringify(stateForPersistence(state), null, 2), "utf-8");
1374
- renameSync(tmp, target);
1387
+ this.writeStateAtomically(target, stateForPersistence(state));
1375
1388
  return { ok: true };
1376
1389
  }
1377
1390
  finally {
@@ -1383,6 +1396,24 @@ export class SessionManager {
1383
1396
  }
1384
1397
  }
1385
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
+ }
1386
1417
  acquireStateLock(target) {
1387
1418
  const lockPath = `${target}.lock`;
1388
1419
  for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
@@ -1644,7 +1675,7 @@ export class SessionManager {
1644
1675
  const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
1645
1676
  let published = false;
1646
1677
  try {
1647
- mkdirSync(stagingDir);
1678
+ mkdirSync(stagingDir, { mode: 0o700 });
1648
1679
  writeFileSync(join(stagingDir, "state.json"), JSON.stringify(state, null, 2), {
1649
1680
  encoding: "utf-8",
1650
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
  *