@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
@@ -1,11 +1,88 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
- import { join, dirname } from "node:path";
1
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { credentialAllowsEnvExposure, credentialSecretHint, } from "./types.js";
5
5
  import { getDefaultCredentialCipher } from "./cipher.js";
6
6
  import { logger } from "../logging/logger.js";
7
7
  import { summarizeOAuthCredentialSecret } from "./oauth.js";
8
8
  import { isBrowserOAuthLinkCredential } from "./oauth.js";
9
+ import { acquireFileLock, writeFileAtomic } from "../utils/file-mutex.js";
10
+ const MAX_CREDENTIALS = 4_096;
11
+ const MAX_CREDENTIAL_FILE_BYTES = 32 * 1024 * 1024;
12
+ const MAX_CREDENTIAL_SECRET_BYTES = 16 * 1024 * 1024;
13
+ const MAX_CREDENTIAL_META_BYTES = 1024 * 1024;
14
+ const MAX_CREDENTIAL_ID_CHARS = 512;
15
+ const MAX_CREDENTIAL_LABEL_CHARS = 4_096;
16
+ function normalizeCredential(value, strict) {
17
+ const invalid = (message) => {
18
+ if (strict)
19
+ throw new Error(`invalid credential: ${message}`);
20
+ return undefined;
21
+ };
22
+ if (!value || typeof value !== "object" || Array.isArray(value))
23
+ return invalid("object");
24
+ const raw = value;
25
+ if (typeof raw.id !== "string" ||
26
+ !raw.id ||
27
+ raw.id.length > MAX_CREDENTIAL_ID_CHARS ||
28
+ raw.id.includes("\0")) {
29
+ return invalid("id");
30
+ }
31
+ if (raw.type !== "token" &&
32
+ raw.type !== "link" &&
33
+ raw.type !== "cookie" &&
34
+ raw.type !== "oauth") {
35
+ return invalid("type");
36
+ }
37
+ if (typeof raw.label !== "string" ||
38
+ raw.label.length > MAX_CREDENTIAL_LABEL_CHARS ||
39
+ raw.label.includes("\0")) {
40
+ return invalid("label");
41
+ }
42
+ if (raw.secret !== undefined &&
43
+ (typeof raw.secret !== "string" || Buffer.byteLength(raw.secret) > MAX_CREDENTIAL_SECRET_BYTES)) {
44
+ return invalid("secret");
45
+ }
46
+ if (raw.exposeAsEnv !== undefined &&
47
+ (typeof raw.exposeAsEnv !== "string" ||
48
+ raw.exposeAsEnv.length > 512 ||
49
+ raw.exposeAsEnv.includes("\0"))) {
50
+ return invalid("exposeAsEnv");
51
+ }
52
+ if (raw.autoUseByAI !== undefined && typeof raw.autoUseByAI !== "boolean") {
53
+ return invalid("autoUseByAI");
54
+ }
55
+ if (raw.autoInjectByAI !== undefined && typeof raw.autoInjectByAI !== "boolean") {
56
+ return invalid("autoInjectByAI");
57
+ }
58
+ let meta;
59
+ if (raw.meta !== undefined) {
60
+ if (!raw.meta || typeof raw.meta !== "object" || Array.isArray(raw.meta)) {
61
+ return invalid("meta");
62
+ }
63
+ try {
64
+ const encoded = JSON.stringify(raw.meta);
65
+ if (Buffer.byteLength(encoded) > MAX_CREDENTIAL_META_BYTES)
66
+ return invalid("meta");
67
+ // Clone through JSON so callers cannot retain a mutable/cyclic object or
68
+ // smuggle a surprising prototype into later credential consumers.
69
+ meta = JSON.parse(encoded);
70
+ }
71
+ catch {
72
+ return invalid("meta");
73
+ }
74
+ }
75
+ return {
76
+ id: raw.id,
77
+ type: raw.type,
78
+ label: raw.label,
79
+ ...(typeof raw.secret === "string" ? { secret: raw.secret } : {}),
80
+ ...(typeof raw.exposeAsEnv === "string" ? { exposeAsEnv: raw.exposeAsEnv } : {}),
81
+ ...(typeof raw.autoUseByAI === "boolean" ? { autoUseByAI: raw.autoUseByAI } : {}),
82
+ ...(typeof raw.autoInjectByAI === "boolean" ? { autoInjectByAI: raw.autoInjectByAI } : {}),
83
+ ...(meta ? { meta } : {}),
84
+ };
85
+ }
9
86
  /** 测试可经 process.env.HOME 覆盖(镜像 settings/manager.ts userHome)。 */
10
87
  function userHome() {
11
88
  return process.env.HOME ?? homedir();
@@ -40,6 +117,9 @@ export class CredentialStore {
40
117
  this.cipher = cipher ?? getDefaultCredentialCipher();
41
118
  }
42
119
  pathFor(scope) {
120
+ if (scope !== "user" && scope !== "project") {
121
+ throw new Error(`invalid credential scope: ${String(scope)}`);
122
+ }
43
123
  if (scope === "user") {
44
124
  return join(this.userDirOverride ?? join(userHome(), ".code-shell"), "credentials.json");
45
125
  }
@@ -66,54 +146,165 @@ export class CredentialStore {
66
146
  }
67
147
  }
68
148
  read(scope) {
149
+ return this.readGuarded(scope).file;
150
+ }
151
+ /**
152
+ * Read the store, reporting whether the on-disk state was fully understood.
153
+ *
154
+ * `readable: false` means "there is a file here but this build could not
155
+ * parse it" — NOT "there are no credentials". The distinction matters because
156
+ * mutate() commits whatever this returns: treating an unreadable file as an
157
+ * empty list turns any later save() into a wipe of every stored credential.
158
+ *
159
+ * `unknown` carries entries this build's schema rejects (for example a
160
+ * credential type added by a newer version). They are kept verbatim so a
161
+ * round-trip through an older build preserves rather than deletes them.
162
+ */
163
+ readGuarded(scope) {
164
+ const empty = { ...EMPTY, credentials: [] };
69
165
  const p = this.pathFor(scope);
70
- if (!p || !existsSync(p))
71
- return { ...EMPTY, credentials: [] };
166
+ if (!p)
167
+ return { file: empty, readable: true, unknown: [] };
168
+ let descriptor;
72
169
  try {
73
- const raw = JSON.parse(readFileSync(p, "utf8"));
74
- const creds = Array.isArray(raw.credentials) ? raw.credentials : [];
170
+ const parent = lstatSync(dirname(p));
171
+ if (parent.isSymbolicLink() || !parent.isDirectory()) {
172
+ logger.warn("credentials.invalid_parent", { path: p });
173
+ return { file: empty, readable: false, unknown: [] };
174
+ }
175
+ const metadata = lstatSync(p);
176
+ if (metadata.isSymbolicLink() ||
177
+ !metadata.isFile() ||
178
+ metadata.size > MAX_CREDENTIAL_FILE_BYTES) {
179
+ logger.warn("credentials.file_too_large", { path: p });
180
+ return { file: empty, readable: false, unknown: [] };
181
+ }
182
+ descriptor = openSync(p, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
183
+ const opened = fstatSync(descriptor);
184
+ if (!opened.isFile() || opened.size > MAX_CREDENTIAL_FILE_BYTES) {
185
+ return { file: empty, readable: false, unknown: [] };
186
+ }
187
+ const raw = JSON.parse(readFileSync(descriptor, "utf8"));
188
+ const values = Array.isArray(raw.credentials)
189
+ ? raw.credentials.slice(0, MAX_CREDENTIALS)
190
+ : [];
191
+ const creds = [];
192
+ const unknown = [];
75
193
  // Decrypt secrets at the disk boundary so all callers see plaintext.
76
- for (const c of creds) {
194
+ for (const value of values) {
195
+ const c = normalizeCredential(value, false);
196
+ if (!c) {
197
+ unknown.push(value);
198
+ continue;
199
+ }
77
200
  if (typeof c.secret === "string" && c.secret.length > 0) {
78
201
  c.secret = this.decryptSecret(c.secret);
79
202
  }
203
+ creds.push(c);
80
204
  }
81
- return { version: 1, credentials: creds };
205
+ return { file: { version: 1, credentials: creds }, readable: true, unknown };
82
206
  }
83
- catch {
84
- return { ...EMPTY, credentials: [] };
207
+ catch (error) {
208
+ // A missing file is genuinely empty; anything else (torn JSON, EACCES)
209
+ // is unreadable and must not be committed over.
210
+ if (error.code === "ENOENT") {
211
+ return { file: empty, readable: true, unknown: [] };
212
+ }
213
+ logger.warn("credentials.unreadable", { path: p, error: error.message });
214
+ return { file: empty, readable: false, unknown: [] };
215
+ }
216
+ finally {
217
+ if (descriptor !== undefined)
218
+ closeSync(descriptor);
85
219
  }
86
220
  }
87
- write(scope, file) {
221
+ write(scope, file,
222
+ /** Entries this build's schema rejected; re-emitted verbatim so an older
223
+ * build cannot delete a newer build's credential types. */
224
+ preserved = []) {
88
225
  const p = this.pathFor(scope);
89
226
  if (!p)
90
227
  return;
91
- mkdirSync(dirname(p), { recursive: true });
92
228
  // Encrypt secrets at the disk boundary. `file` carries plaintext secrets
93
229
  // (read() decrypted them); serialize a copy with each secret encrypted so
94
230
  // we never persist plaintext under an encrypting cipher.
95
231
  const onDisk = {
96
232
  version: file.version,
97
- credentials: file.credentials.map((c) => typeof c.secret === "string" && c.secret.length > 0
98
- ? { ...c, secret: this.cipher.encrypt(c.secret) }
99
- : c),
233
+ credentials: [
234
+ ...file.credentials.map((c) => typeof c.secret === "string" && c.secret.length > 0
235
+ ? { ...c, secret: this.cipher.encrypt(c.secret) }
236
+ : c),
237
+ ...preserved,
238
+ ],
100
239
  };
101
- const tmp = `${p}.${process.pid}.${String(performance.now()).replace(".", "")}.tmp`;
102
- writeFileSync(tmp, JSON.stringify(onDisk, null, 2), { mode: 0o600 });
103
- renameSync(tmp, p);
240
+ const encoded = JSON.stringify(onDisk, null, 2);
241
+ if (Buffer.byteLength(encoded) > MAX_CREDENTIAL_FILE_BYTES) {
242
+ throw new Error("credential store exceeds the maximum file size");
243
+ }
244
+ const parentPath = dirname(p);
245
+ mkdirSync(parentPath, { recursive: true, mode: 0o700 });
246
+ const parent = lstatSync(parentPath);
247
+ if (parent.isSymbolicLink() || !parent.isDirectory()) {
248
+ throw new Error("credential store parent must be a real directory");
249
+ }
250
+ try {
251
+ const target = lstatSync(p);
252
+ if (target.isSymbolicLink() || !target.isFile()) {
253
+ throw new Error("credential store target must be a regular file");
254
+ }
255
+ }
256
+ catch (error) {
257
+ if (error.code !== "ENOENT")
258
+ throw error;
259
+ }
260
+ writeFileAtomic(p, encoded, 0o600);
261
+ }
262
+ mutate(scope, change) {
263
+ const p = this.pathFor(scope);
264
+ if (!p)
265
+ return;
266
+ const parent = dirname(p);
267
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
268
+ const parentInfo = lstatSync(parent);
269
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
270
+ throw new Error("credential store parent must be a real directory");
271
+ }
272
+ const release = acquireFileLock(p);
273
+ try {
274
+ // Reload only after acquiring the cross-process lock; otherwise two app
275
+ // instances can each write back a stale snapshot and silently lose one.
276
+ const { file, readable, unknown } = this.readGuarded(scope);
277
+ // Refuse to commit over state we could not parse. Writing here would
278
+ // replace every existing credential with whatever this mutation adds.
279
+ if (!readable) {
280
+ throw new Error("refusing to modify an unreadable credential store; " +
281
+ "move the corrupt file aside to start a new one");
282
+ }
283
+ if (change(file))
284
+ this.write(scope, file, unknown);
285
+ }
286
+ finally {
287
+ release();
288
+ }
104
289
  }
105
290
  /** Upsert by id within a scope. */
106
291
  save(scope, cred) {
107
- const file = this.read(scope);
108
- const safeCredential = credentialAllowsEnvExposure(cred.type)
109
- ? cred
110
- : { ...cred, exposeAsEnv: undefined };
111
- const idx = file.credentials.findIndex((c) => c.id === cred.id);
112
- if (idx >= 0)
113
- file.credentials[idx] = safeCredential;
114
- else
115
- file.credentials.push(safeCredential);
116
- this.write(scope, file);
292
+ const normalized = normalizeCredential(cred, true);
293
+ const safeCredential = credentialAllowsEnvExposure(normalized.type)
294
+ ? normalized
295
+ : { ...normalized, exposeAsEnv: undefined };
296
+ this.mutate(scope, (file) => {
297
+ const idx = file.credentials.findIndex((c) => c.id === safeCredential.id);
298
+ if (idx >= 0)
299
+ file.credentials[idx] = safeCredential;
300
+ else {
301
+ if (file.credentials.length >= MAX_CREDENTIALS) {
302
+ throw new Error("credential store has reached its maximum entry count");
303
+ }
304
+ file.credentials.push(safeCredential);
305
+ }
306
+ return true;
307
+ });
117
308
  }
118
309
  /**
119
310
  * 只改元数据(label / exposeAsEnv / autoUseByAI / meta),保留 secret 原样。
@@ -121,22 +312,34 @@ export class CredentialStore {
121
312
  * id 不存在则 no-op。
122
313
  */
123
314
  patch(scope, id, fields) {
124
- const file = this.read(scope);
125
- const idx = file.credentials.findIndex((c) => c.id === id);
126
- if (idx < 0)
127
- return;
128
- const current = file.credentials[idx];
129
- file.credentials[idx] = {
130
- ...current,
131
- ...fields,
132
- ...(credentialAllowsEnvExposure(current.type) ? {} : { exposeAsEnv: undefined }),
133
- };
134
- this.write(scope, file);
315
+ if (typeof id !== "string" || !id || id.length > MAX_CREDENTIAL_ID_CHARS || id.includes("\0")) {
316
+ throw new Error("invalid credential id");
317
+ }
318
+ this.mutate(scope, (file) => {
319
+ const idx = file.credentials.findIndex((c) => c.id === id);
320
+ if (idx < 0)
321
+ return false;
322
+ const current = file.credentials[idx];
323
+ const updated = normalizeCredential({
324
+ ...current,
325
+ ...fields,
326
+ ...(credentialAllowsEnvExposure(current.type) ? {} : { exposeAsEnv: undefined }),
327
+ }, true);
328
+ file.credentials[idx] = updated;
329
+ return true;
330
+ });
135
331
  }
136
332
  remove(scope, id) {
137
- const file = this.read(scope);
138
- file.credentials = file.credentials.filter((c) => c.id !== id);
139
- this.write(scope, file);
333
+ if (typeof id !== "string" || !id || id.length > MAX_CREDENTIAL_ID_CHARS || id.includes("\0")) {
334
+ throw new Error("invalid credential id");
335
+ }
336
+ this.mutate(scope, (file) => {
337
+ const next = file.credentials.filter((c) => c.id !== id);
338
+ if (next.length === file.credentials.length)
339
+ return false;
340
+ file.credentials = next;
341
+ return true;
342
+ });
140
343
  }
141
344
  /**
142
345
  * List credentials visible to an engine of the given settings scope.
@@ -44,7 +44,7 @@ import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
44
44
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
45
45
  import { PromptCacheDiagnosticRecorder, promptCacheDropHint, } from "./prompt-cache-diagnostics.js";
46
46
  import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
47
- import { QUICK_CHAT_RESTRICTED_PROFILE, } from "./run-types.js";
47
+ import { ISOLATED_TASK_PROFILE, QUICK_CHAT_RESTRICTED_PROFILE, } from "./run-types.js";
48
48
  import { createSubAgentSpawner } from "./subagent-spawner.js";
49
49
  import { AuxiliaryPipeline, sameLlmIdentity } from "./auxiliary-pipeline.js";
50
50
  import { PermissionController } from "./permission-controller.js";
@@ -413,6 +413,7 @@ export class Engine {
413
413
  // extension modules — later registrations override earlier ones by id.
414
414
  this.behaviorProfiles = new Map([
415
415
  QUICK_CHAT_RESTRICTED_PROFILE,
416
+ ISOLATED_TASK_PROFILE,
416
417
  ...(config.behaviorProfiles ?? []),
417
418
  ...(config.extensionModules ?? []).flatMap((module) => module.behaviorProfiles ?? []),
418
419
  ].map((profile) => [profile.id, profile]));
@@ -1069,10 +1070,6 @@ export class Engine {
1069
1070
  // surfacing as `[-32603] Session not found: <sid>` on the very first
1070
1071
  // TUI turn. Detection now uses `sessionManager.exists()` (one stat
1071
1072
  // call) instead of a try/catch on resume.
1072
- // wrappedOnStream (defined before the session opens, executed only after)
1073
- // closes over `session`, so keep the declaration here and assign from the
1074
- // opener's result.
1075
- let session;
1076
1073
  const openedResult = openRunSession({
1077
1074
  sessionManager: this.sessionManager,
1078
1075
  options,
@@ -1095,20 +1092,65 @@ export class Engine {
1095
1092
  });
1096
1093
  if (!openedResult.ok)
1097
1094
  return openedResult.result;
1098
- const { messages, freshImageMessage, resumedFromDisk, claimClientMessageId, releaseClientMessageId, } = openedResult.opened;
1099
- session = openedResult.opened.session;
1095
+ const { messages: openedMessages, freshImageMessage, resumedFromDisk, claimClientMessageId, releaseClientMessageId, } = openedResult.opened;
1096
+ const session = openedResult.opened.session;
1097
+ let messages = openedMessages;
1098
+ // A host-owned time/topic boundary must be applied only after the current
1099
+ // user message exists in the transcript (it is the exclusive end anchor),
1100
+ // yet before this turn assembles prompts or calls the model. Doing this as
1101
+ // a separate host query races both sides of that boundary: too early and
1102
+ // the anchor is dead; too late and the first new-topic prompt still sees
1103
+ // the entire old topic. Fail open to full history if summarization fails.
1104
+ if (options?.archiveBeforeCurrentTurn && options.clientMessageId) {
1105
+ try {
1106
+ const archived = await this.archiveTurnRange(session.state.sessionId, { start: 0, end: 0 }, {
1107
+ toClientMessageId: options.clientMessageId,
1108
+ ...(options.archiveBeforeCurrentTurn.fromClientMessageId
1109
+ ? {
1110
+ fromClientMessageId: options.archiveBeforeCurrentTurn.fromClientMessageId,
1111
+ }
1112
+ : {}),
1113
+ ...(options.archiveBeforeCurrentTurn.segmentId
1114
+ ? { segmentId: options.archiveBeforeCurrentTurn.segmentId }
1115
+ : {}),
1116
+ });
1117
+ if (archived.before > archived.after) {
1118
+ options.onStream?.({
1119
+ type: "context_compact",
1120
+ strategy: "range",
1121
+ before: archived.before,
1122
+ after: archived.after,
1123
+ });
1124
+ }
1125
+ // openRunSession captured the pre-marker replay. Rebuild from the
1126
+ // persisted marker so this very first post-boundary model call gets
1127
+ // the archived view rather than waiting until the following turn.
1128
+ messages =
1129
+ this.compactedMessagesBySession.get(session.state.sessionId) ??
1130
+ this.sessionManager.resume(session.state.sessionId).transcript.toMessages();
1131
+ }
1132
+ catch (error) {
1133
+ logger.warn("engine.pre_run_archive.failed", {
1134
+ sessionId: session.state.sessionId,
1135
+ segmentId: options.archiveBeforeCurrentTurn.segmentId,
1136
+ error: error instanceof Error ? error.message : String(error),
1137
+ });
1138
+ }
1139
+ }
1100
1140
  this.stampRunToolContext(toolCtx, session, options);
1101
1141
  const sessionRun = runWithSid(session.state.sessionId, async () => {
1102
- const hookMessages = await this.runSessionStartHooks({
1103
- session,
1104
- task,
1105
- cwd,
1106
- runPermissionMode,
1107
- resumedFromDisk,
1108
- options,
1109
- taskText,
1110
- messages,
1111
- });
1142
+ const hookMessages = profile?.disableHooks
1143
+ ? []
1144
+ : await this.runSessionStartHooks({
1145
+ session,
1146
+ task,
1147
+ cwd,
1148
+ runPermissionMode,
1149
+ resumedFromDisk,
1150
+ options,
1151
+ taskText,
1152
+ messages,
1153
+ });
1112
1154
  const sid = session.state.sessionId;
1113
1155
  const { contextManager, llmClientPromise, toolExecutor } = this.wireRunContextAndPermission({
1114
1156
  session,
@@ -1939,17 +1981,23 @@ export class Engine {
1939
1981
  // A profile whose tool allowlist excludes the Skill tool can never invoke
1940
1982
  // a skill, so the full skills listing would be dead context for every one
1941
1983
  // of its turns (e.g. the Pet manager) — inject none via an empty allowlist.
1942
- const profileCanUseSkills = !profile?.allowedToolNames || profile.allowedToolNames.has(skillToolDef.name);
1984
+ const runAllowedToolNames = toolCtx.allowedToolNames;
1985
+ const profileCanUseSkills = !runAllowedToolNames || runAllowedToolNames.has(skillToolDef.name);
1943
1986
  const promptComposer = new PromptComposer(buildPromptComposerConfig({
1944
1987
  cwd,
1945
1988
  model: this.config.llm.model,
1946
1989
  preset: this.preset,
1947
- customSystemPrompt: this.config.customSystemPrompt,
1948
- appendSystemPrompt: [this.config.appendSystemPrompt, profile?.systemPromptAppend]
1990
+ customSystemPrompt: profile?.disableInstructions
1991
+ ? undefined
1992
+ : this.config.customSystemPrompt,
1993
+ appendSystemPrompt: [
1994
+ profile?.disableInstructions ? undefined : this.config.appendSystemPrompt,
1995
+ profile?.systemPromptAppend,
1996
+ ]
1949
1997
  .filter(Boolean)
1950
1998
  .join("\n\n") || undefined,
1951
1999
  responseLanguage: this.config.responseLanguage,
1952
- userProfile: this.config.userProfile,
2000
+ userProfile: profile?.disableInstructions ? undefined : this.config.userProfile,
1953
2001
  workspaceProfile: runWorkspaceProfile,
1954
2002
  // Read from the Session's own persisted state, so every turn — not just
1955
2003
  // the first — carries the standing brief.
@@ -1959,15 +2007,21 @@ export class Engine {
1959
2007
  instructionBoundaryFinder: (scanCwd) => resolveInstructionBoundary(scanCwd, this.capabilities),
1960
2008
  disabledSkills,
1961
2009
  disabledPlugins,
1962
- skillAllowlist: profileCanUseSkills ? this.config.skillAllowlist : [],
2010
+ skillAllowlist: profileCanUseSkills ? toolCtx.skillAllowlist : [],
1963
2011
  memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1964
2012
  memoryCurrentProjectOnly: profile?.memoryCurrentProjectOnly,
1965
- goalToolState: !profile?.allowedToolNames ||
1966
- profile.allowedToolNames.has("complete_goal") ||
1967
- profile.allowedToolNames.has("cancel_goal")
2013
+ disableInstructions: profile?.disableInstructions,
2014
+ disableMemoryContext: profile?.disableMemoryContext,
2015
+ disableCapabilityContext: profile?.disableCapabilityContext,
2016
+ disableSourcesContext: profile?.disableSourcesContext,
2017
+ goalToolState: !runAllowedToolNames ||
2018
+ runAllowedToolNames.has("complete_goal") ||
2019
+ runAllowedToolNames.has("cancel_goal")
1968
2020
  ? { hasGoal: hasRunnableGoal }
1969
2021
  : undefined,
1970
- capabilityPromptSections: this.capabilityPromptSections,
2022
+ capabilityPromptSections: profile?.disableCapabilityContext
2023
+ ? {}
2024
+ : this.capabilityPromptSections,
1971
2025
  dynamicContextProviders: this.capabilityDynamicContextProviders,
1972
2026
  getSettingsManager: () => this.getSettingsManager(),
1973
2027
  toolCatalog: this.toolCatalog,
@@ -2024,7 +2078,7 @@ export class Engine {
2024
2078
  toolRewriters: this.toolRewriters,
2025
2079
  toolFeatureFlags: TOOL_FEATURE_FLAGS,
2026
2080
  applyBuiltinOverrideVisibility,
2027
- profileAllowedToolNames: profile?.allowedToolNames,
2081
+ profileAllowedToolNames: runAllowedToolNames,
2028
2082
  runPlanMode,
2029
2083
  });
2030
2084
  return { promptComposer, toolDefs };
@@ -1,13 +1,19 @@
1
1
  import { FileHistory } from "../session/file-history.js";
2
2
  export function registerFileHistoryHook(options) {
3
3
  const history = FileHistory.loadFromDir(options.sessionDir);
4
- const handler = async (context) => {
4
+ const pendingCreates = new Map();
5
+ const startHandler = async (context) => {
5
6
  const toolName = context.data?.toolName;
6
7
  const args = context.data?.args;
8
+ const toolCallId = context.data?.toolCallId;
7
9
  const turnSeq = options.getTurnSeq();
8
10
  if ((toolName === "Write" || toolName === "Edit") && typeof args?.file_path === "string") {
9
- if (history.saveSnapshot(args.file_path, turnSeq) === null && turnSeq !== undefined) {
10
- history.recordCreated(args.file_path, turnSeq);
11
+ const marker = turnSeq === undefined ? null : history.prepareCreated(args.file_path, turnSeq);
12
+ if (marker && typeof toolCallId === "string") {
13
+ pendingCreates.set(toolCallId, marker);
14
+ }
15
+ else {
16
+ history.saveSnapshot(args.file_path, turnSeq);
11
17
  }
12
18
  }
13
19
  else if (args) {
@@ -18,14 +24,27 @@ export function registerFileHistoryHook(options) {
18
24
  }
19
25
  return {};
20
26
  };
21
- options.hooks.register("on_tool_start", handler, 100, "file_history_backup");
27
+ const endHandler = async (context) => {
28
+ const toolCallId = context.data?.toolCallId;
29
+ if (typeof toolCallId !== "string")
30
+ return {};
31
+ const marker = pendingCreates.get(toolCallId);
32
+ pendingCreates.delete(toolCallId);
33
+ if (marker && context.data?.isError !== true)
34
+ history.commitCreated(marker);
35
+ return {};
36
+ };
37
+ options.hooks.register("on_tool_start", startHandler, 100, "file_history_backup");
38
+ options.hooks.register("on_tool_end", endHandler, 100, "file_history_backup");
22
39
  let disposed = false;
23
40
  return {
24
41
  dispose() {
25
42
  if (disposed)
26
43
  return;
27
44
  disposed = true;
28
- options.hooks.unregister("on_tool_start", handler);
45
+ pendingCreates.clear();
46
+ options.hooks.unregister("on_tool_start", startHandler);
47
+ options.hooks.unregister("on_tool_end", endHandler);
29
48
  },
30
49
  };
31
50
  }
@@ -88,11 +88,13 @@ export async function finalizeRunSuccess(args) {
88
88
  // the turn loop has resolved (completion, error, or abort). Handlers
89
89
  // are notify-only — any returned messages are dropped because the run
90
90
  // is already over and there's no next turn to inject into.
91
- await args.emitHook("on_session_end", {
92
- sessionId: session.state.sessionId,
93
- reason: result.reason,
94
- turnCount,
95
- }, options?.signal);
91
+ if (profile?.disableHooks !== true) {
92
+ await args.emitHook("on_session_end", {
93
+ sessionId: session.state.sessionId,
94
+ reason: result.reason,
95
+ turnCount,
96
+ }, options?.signal);
97
+ }
96
98
  // Ephemeral side chats must never leak into durable memory, even after
97
99
  // the user explicitly elevates tool permissions for a turn. Lifecycle
98
100
  // isolation is independent of the run-scoped behavior/permission mode.
@@ -106,7 +108,7 @@ export async function finalizeRunSuccess(args) {
106
108
  // Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
107
109
  // effort: failures never touch the run result. The renderer writes the
108
110
  // title into the sidebar on receipt of the session_title stream event.
109
- {
111
+ if (profile?.disableSessionTitle !== true) {
110
112
  const messageEvents = session.transcript.getEvents("message");
111
113
  const userMsgEvents = messageEvents.filter((e) => e.data.role === "user");
112
114
  const userMsgCount = userMsgEvents.length;
@@ -113,7 +113,7 @@ export function openRunSession(args) {
113
113
  else {
114
114
  // Cold start: shape (2) reuses the host-supplied sid; shape (3)
115
115
  // lets sessionManager generate one with nanoid.
116
- session = args.sessionManager.create(args.cwd, args.llmModel, args.llmProvider, options?.sessionId, args.isSubAgent ? getCurrentSid() : undefined, args.isSubAgent ? "subagent" : args.origin, args.sessionKind);
116
+ session = args.sessionManager.create(args.cwd, args.llmModel, args.llmProvider, options?.sessionId, args.isSubAgent ? getCurrentSid() : undefined, args.isSubAgent ? "subagent" : args.origin, args.sessionKind, options?.ephemeral === true);
117
117
  const userMsg = { role: "user", content: args.userMessageContent };
118
118
  claimClientMessageId(session, options?.clientMessageId, "submit");
119
119
  if (args.parsedTask.hasImages)
@@ -33,6 +33,10 @@ export interface RunPromptComposerConfigInput {
33
33
  skillAllowlist: ComposerOptions["skillAllowlist"];
34
34
  memoriesMaxAgeDays: ComposerOptions["memoriesMaxAgeDays"];
35
35
  memoryCurrentProjectOnly?: ComposerOptions["memoryCurrentProjectOnly"];
36
+ disableInstructions?: ComposerOptions["disableInstructions"];
37
+ disableMemoryContext?: ComposerOptions["disableMemoryContext"];
38
+ disableCapabilityContext?: ComposerOptions["disableCapabilityContext"];
39
+ disableSourcesContext?: ComposerOptions["disableSourcesContext"];
36
40
  goalToolState: ComposerOptions["goalToolState"];
37
41
  capabilityPromptSections: ComposerOptions["capabilityPromptSections"];
38
42
  dynamicContextProviders: ComposerOptions["dynamicContextProviders"];
@@ -23,7 +23,7 @@ export function resolveRunProfileState(args) {
23
23
  }
24
24
  /** Build the prompt-composer options for a run without capturing the Engine facade. */
25
25
  export function buildPromptComposerConfig(args) {
26
- const { cwd, model, preset, customSystemPrompt, appendSystemPrompt, responseLanguage, userProfile, workspaceProfile, sessionBrief, profileMemoryDir, instructionCompatFileNames, instructionBoundaryFinder, disabledSkills, disabledPlugins, skillAllowlist, memoriesMaxAgeDays, memoryCurrentProjectOnly, goalToolState, capabilityPromptSections, dynamicContextProviders, getSettingsManager, toolCatalog, } = args;
26
+ const { cwd, model, preset, customSystemPrompt, appendSystemPrompt, responseLanguage, userProfile, workspaceProfile, sessionBrief, profileMemoryDir, instructionCompatFileNames, instructionBoundaryFinder, disabledSkills, disabledPlugins, skillAllowlist, memoriesMaxAgeDays, memoryCurrentProjectOnly, disableInstructions, disableMemoryContext, disableCapabilityContext, disableSourcesContext, goalToolState, capabilityPromptSections, dynamicContextProviders, getSettingsManager, toolCatalog, } = args;
27
27
  return {
28
28
  cwd,
29
29
  model,
@@ -50,6 +50,10 @@ export function buildPromptComposerConfig(args) {
50
50
  skillAllowlist,
51
51
  memoriesMaxAgeDays,
52
52
  memoryCurrentProjectOnly,
53
+ disableInstructions,
54
+ disableMemoryContext,
55
+ disableCapabilityContext,
56
+ disableSourcesContext,
53
57
  goalToolState,
54
58
  capabilityPromptSections,
55
59
  dynamicContextProviders,
@@ -51,8 +51,14 @@ export function buildRunToolContext(args) {
51
51
  return reason;
52
52
  },
53
53
  },
54
+ skillAllowlist: options?.skillAllowlist !== undefined
55
+ ? [...options.skillAllowlist]
56
+ : args.base.skillAllowlist,
54
57
  };
55
- if (profile?.allowedToolNames) {
58
+ if (options?.toolAllowlist !== undefined) {
59
+ toolCtx.allowedToolNames = new Set(options.toolAllowlist);
60
+ }
61
+ else if (profile?.allowedToolNames) {
56
62
  toolCtx.allowedToolNames = profile.allowedToolNames;
57
63
  }
58
64
  if (profile?.createRunServices) {