@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
@@ -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.
@@ -1070,10 +1070,6 @@ export class Engine {
1070
1070
  // surfacing as `[-32603] Session not found: <sid>` on the very first
1071
1071
  // TUI turn. Detection now uses `sessionManager.exists()` (one stat
1072
1072
  // call) instead of a try/catch on resume.
1073
- // wrappedOnStream (defined before the session opens, executed only after)
1074
- // closes over `session`, so keep the declaration here and assign from the
1075
- // opener's result.
1076
- let session;
1077
1073
  const openedResult = openRunSession({
1078
1074
  sessionManager: this.sessionManager,
1079
1075
  options,
@@ -1096,8 +1092,51 @@ export class Engine {
1096
1092
  });
1097
1093
  if (!openedResult.ok)
1098
1094
  return openedResult.result;
1099
- const { messages, freshImageMessage, resumedFromDisk, claimClientMessageId, releaseClientMessageId, } = openedResult.opened;
1100
- 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
+ }
1101
1140
  this.stampRunToolContext(toolCtx, session, options);
1102
1141
  const sessionRun = runWithSid(session.state.sessionId, async () => {
1103
1142
  const hookMessages = profile?.disableHooks
@@ -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
  }
@@ -98,6 +98,15 @@ export interface EngineRunOptions {
98
98
  goal?: string | GoalConfig;
99
99
  injected?: boolean;
100
100
  clientMessageId?: string;
101
+ /**
102
+ * Host-requested history boundary to archive after this run's user message
103
+ * has been appended, but before the first model call. The current
104
+ * `clientMessageId` is the exclusive end anchor.
105
+ */
106
+ archiveBeforeCurrentTurn?: {
107
+ fromClientMessageId?: string;
108
+ segmentId?: string;
109
+ };
101
110
  attachments?: InputAttachmentMeta[];
102
111
  /** Named per-run behavior profile supplied by interactive product surfaces. */
103
112
  behaviorMode?: RunBehaviorMode;
@@ -22,7 +22,7 @@ import { COMPLETE_GOAL_TOOL_NAME } from "../tool-system/builtin/complete-goal.js
22
22
  import { CANCEL_GOAL_TOOL_NAME } from "../tool-system/builtin/cancel-goal.js";
23
23
  import { redactSensitiveToolResultsInMessages, SENSITIVE_TOOL_RESULT_PLACEHOLDER, toolResultForDisplay, toolResultTranscriptText, toolResultsForDisplay, } from "../tool-system/tool-result-redaction.js";
24
24
  import { addTokenUsage, cacheHitRateFromUsage, cumulativeCacheHitRate, } from "../session/usage.js";
25
- import { createGoalBudgetTracker, recordGoalUsage, goalBudgetTerminationReason, applyGoalExtension, limitProximity, isSameGoalVersion, normalizeGoal, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "../goal/lifecycle.js";
25
+ import { createGoalBudgetTracker, recordGoalUsage, goalBudgetTerminationReason, applyGoalExtension, extendGoalLimit, limitProximity, isSameGoalVersion, normalizeGoal, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "../goal/lifecycle.js";
26
26
  /**
27
27
  * 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
28
28
  * 有 contentBlocks(view_image 的图片块)就原样用作 content;否则
@@ -148,6 +148,9 @@ export class TurnLoop {
148
148
  * effective limits.
149
149
  */
150
150
  extend(opts) {
151
+ const previousMaxTurns = this.config.maxTurns;
152
+ const previousTokenBudget = this.goalTracker?.goal.tokenBudget;
153
+ const previousTimeBudgetMs = this.goalTracker?.goal.timeBudgetMs;
151
154
  const elapsedMs = this.goalTracker ? Date.now() - this.goalTracker.startedAtMs : 0;
152
155
  const next = applyGoalExtension(this.config.maxTurns, this.goalTracker?.goal, this.goalTracker?.tokensUsed ?? 0, elapsedMs, opts);
153
156
  this.config = { ...this.config, maxTurns: next.maxTurns };
@@ -166,18 +169,16 @@ export class TurnLoop {
166
169
  // limit that actually bites, so an extend that only bumped maxTurns/budgets
167
170
  // couldn't keep it going. Resolve the current cap the same way the loop does.
168
171
  const curCap = this.config.maxStopBlocks ?? GOAL_DEFAULT_MAX_STOP_BLOCKS;
169
- const nextCap = typeof opts.addStopBlocks === "number" && opts.addStopBlocks > 0
170
- ? curCap + Math.floor(opts.addStopBlocks)
171
- : curCap;
172
+ const nextCap = extendGoalLimit(curCap, opts.addStopBlocks);
172
173
  this.config = { ...this.config, maxStopBlocks: nextCap };
173
174
  // ANY extension resets the consecutive stop-block streak: the user just
174
175
  // asked to keep going, so a goal that was repeatedly re-blocked shouldn't be
175
176
  // immediately re-capped. (Previously only addTurns reset it, leaving a
176
177
  // budget-only extension unable to un-stick a capped goal.)
177
- const extended = (opts.addTurns ?? 0) > 0 ||
178
- (opts.addStopBlocks ?? 0) > 0 ||
179
- (opts.addTokenBudget ?? 0) > 0 ||
180
- (opts.addTimeBudgetMs ?? 0) > 0;
178
+ const extended = next.maxTurns !== previousMaxTurns ||
179
+ nextCap !== curCap ||
180
+ next.tokenBudget !== previousTokenBudget ||
181
+ next.timeBudgetMs !== previousTimeBudgetMs;
181
182
  if (extended) {
182
183
  this.stopBlockCount = 0;
183
184
  // Let the next approach re-announce against the raised ceilings.
@@ -83,6 +83,8 @@ export type GoalLifecycleV1 = (GoalLifecycleBaseV1 & {
83
83
  };
84
84
  });
85
85
  export type GoalLifecyclePhase = GoalLifecycleV1["phase"];
86
+ /** Add a user-controlled positive integer delta without producing Infinity or an unsafe integer. */
87
+ export declare function extendGoalLimit(current: number, addition: unknown): number;
86
88
  /** Build one canonical lifecycle record from the compatibility GoalConfig view. */
87
89
  export declare function createGoalLifecycle(goal: GoalConfig, phase?: Exclude<GoalLifecyclePhase, "terminal" | "waiting">, nowMs?: number): GoalLifecycleV1;
88
90
  /** Compatibility view consumed by Engine/protocol while persistence uses the union. */