@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.
- package/dist/automation/scheduler.js +49 -0
- package/dist/automation/store.d.ts +1 -1
- package/dist/automation/store.js +184 -10
- package/dist/cli/agent-server-stdio.js +7 -0
- package/dist/credentials/store.d.ts +14 -0
- package/dist/credentials/store.js +245 -42
- package/dist/engine/engine.js +45 -6
- package/dist/engine/file-history-hook.js +24 -5
- package/dist/engine/run-types.d.ts +9 -0
- package/dist/engine/turn-loop.js +9 -8
- package/dist/goal/lifecycle.d.ts +2 -0
- package/dist/goal/lifecycle.js +56 -33
- package/dist/index.d.ts +2 -3
- package/dist/index.internal.d.ts +1 -0
- package/dist/index.internal.js +1 -0
- package/dist/index.js +2 -2
- package/dist/links/cli.d.ts +2 -0
- package/dist/links/cli.js +11 -4
- package/dist/model-catalog/index.js +19 -4
- package/dist/model-catalog/save-entry.js +122 -61
- package/dist/model-catalog/types.js +27 -23
- package/dist/panel-apps/installer.js +27 -14
- package/dist/panel-apps/registry.js +60 -12
- package/dist/plugins/installedPlugins.d.ts +4 -0
- package/dist/plugins/installedPlugins.js +70 -30
- package/dist/plugins/installer/types.d.ts +12 -12
- package/dist/plugins/installer/update.js +37 -38
- package/dist/plugins/knownMarketplaces.d.ts +7 -3
- package/dist/plugins/knownMarketplaces.js +127 -23
- package/dist/plugins/pluginCatalog.js +18 -4
- package/dist/plugins/pluginHookApproval.js +56 -60
- package/dist/plugins/pluginMcpApproval.js +50 -52
- package/dist/profile/catalog-store.js +39 -4
- package/dist/profile/catalog.js +55 -15
- package/dist/profile/store.js +51 -21
- package/dist/protocol/chat-session-manager.d.ts +9 -0
- package/dist/protocol/chat-session-manager.js +13 -0
- package/dist/protocol/chat-session.d.ts +5 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/server.d.ts +2 -0
- package/dist/protocol/server.js +75 -29
- package/dist/protocol/types.d.ts +8 -0
- package/dist/run/FileRunStore.d.ts +2 -0
- package/dist/run/FileRunStore.js +153 -18
- package/dist/run/Heartbeat.js +63 -4
- package/dist/services/auto-dream.js +39 -17
- package/dist/services/session-memory.js +107 -8
- package/dist/session/file-history.d.ts +63 -2
- package/dist/session/file-history.js +593 -86
- package/dist/session/session-manager.d.ts +1 -0
- package/dist/session/session-manager.js +52 -21
- package/dist/session/transcript.js +33 -3
- package/dist/session/undo-target.d.ts +15 -6
- package/dist/session/undo-target.js +26 -9
- package/dist/settings/manager.d.ts +22 -3
- package/dist/settings/manager.js +185 -50
- package/dist/settings/schema.d.ts +3 -3
- package/dist/sources/adapters/local-files.js +49 -4
- package/dist/sources/catalog.js +64 -18
- package/dist/sources/types.d.ts +3 -3
- package/dist/sources/types.js +7 -4
- package/dist/themes/installer.js +192 -28
- package/dist/tool-system/builtin/add-marketplace.js +21 -1
- package/dist/tool-system/builtin/cron.d.ts +2 -1
- package/dist/tool-system/builtin/cron.js +20 -6
- package/dist/tool-system/builtin/index.js +44 -0
- package/dist/tool-system/builtin/install-capability.d.ts +52 -0
- package/dist/tool-system/builtin/install-capability.js +1057 -0
- package/dist/tool-system/builtin/skill.js +3 -1
- package/dist/tool-system/executor.js +1 -0
- package/dist/tool-system/registry.js +5 -0
- package/dist/utils/file-mutex.d.ts +2 -0
- package/dist/utils/file-mutex.js +29 -4
- package/package.json +2 -1
|
@@ -1,11 +1,88 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
71
|
-
return {
|
|
166
|
+
if (!p)
|
|
167
|
+
return { file: empty, readable: true, unknown: [] };
|
|
168
|
+
let descriptor;
|
|
72
169
|
try {
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
108
|
-
const safeCredential = credentialAllowsEnvExposure(
|
|
109
|
-
?
|
|
110
|
-
: { ...
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
package/dist/engine/engine.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/engine/turn-loop.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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.
|
package/dist/goal/lifecycle.d.ts
CHANGED
|
@@ -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. */
|