@cjhyy/code-shell-core 0.9.5 → 0.9.6

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 (48) hide show
  1. package/dist/credentials/access.d.ts +9 -1
  2. package/dist/credentials/access.js +15 -4
  3. package/dist/credentials/store.d.ts +11 -0
  4. package/dist/credentials/store.js +44 -9
  5. package/dist/credentials/types.d.ts +4 -0
  6. package/dist/credentials/types.js +4 -0
  7. package/dist/credentials/use-credential-tool.js +12 -2
  8. package/dist/engine/engine-workspace-authority.js +10 -3
  9. package/dist/engine/engine.d.ts +3 -0
  10. package/dist/engine/engine.js +38 -6
  11. package/dist/engine/prompt-cache-diagnostics.js +10 -1
  12. package/dist/engine/run-goal.js +7 -3
  13. package/dist/engine/run-types.d.ts +16 -0
  14. package/dist/engine/run-workspace.js +5 -21
  15. package/dist/engine/turn-loop.d.ts +4 -4
  16. package/dist/engine/turn-loop.js +29 -9
  17. package/dist/index.d.ts +4 -4
  18. package/dist/index.js +2 -2
  19. package/dist/links/index.d.ts +1 -0
  20. package/dist/links/index.js +1 -0
  21. package/dist/links/link-action-tool.d.ts +2 -1
  22. package/dist/links/link-action-tool.js +80 -44
  23. package/dist/links/status.d.ts +53 -0
  24. package/dist/links/status.js +175 -0
  25. package/dist/llm/prompt-cache.d.ts +35 -3
  26. package/dist/llm/prompt-cache.js +63 -3
  27. package/dist/llm/providers/openai.d.ts +3 -0
  28. package/dist/llm/providers/openai.js +57 -14
  29. package/dist/protocol/background-result-wakeup.d.ts +8 -1
  30. package/dist/protocol/background-result-wakeup.js +76 -38
  31. package/dist/protocol/chat-session-manager.d.ts +7 -1
  32. package/dist/protocol/chat-session-manager.js +44 -8
  33. package/dist/protocol/chat-session.d.ts +2 -0
  34. package/dist/protocol/chat-session.js +4 -1
  35. package/dist/protocol/server.d.ts +3 -0
  36. package/dist/protocol/server.js +148 -43
  37. package/dist/protocol/session-message-result.d.ts +12 -0
  38. package/dist/protocol/session-message-result.js +42 -0
  39. package/dist/protocol/session-message-workspace.d.ts +21 -0
  40. package/dist/protocol/session-message-workspace.js +57 -0
  41. package/dist/protocol/types.d.ts +2 -0
  42. package/dist/session/session-message.d.ts +17 -2
  43. package/dist/tool-system/browser-bridge.d.ts +18 -0
  44. package/dist/tool-system/builtin/agent.js +23 -9
  45. package/dist/tool-system/builtin/browser-tools.js +18 -1
  46. package/dist/tool-system/builtin/index.js +3 -3
  47. package/dist/tool-system/builtin/send-message-to-session.js +19 -3
  48. package/package.json +1 -1
@@ -16,6 +16,11 @@ export interface CredentialMetadata {
16
16
  }
17
17
  export interface CredentialAccess {
18
18
  listMasked(cwd: string | undefined, scope: CredentialAccessScope): CredentialMetadata[];
19
+ /** Optional read diagnostics. False means the list may be incomplete, not that it is empty. */
20
+ listMaskedWithStatus?(cwd: string | undefined, scope: CredentialAccessScope): {
21
+ credentials: CredentialMetadata[];
22
+ readable: boolean;
23
+ };
19
24
  resolveMeta(cwd: string | undefined, id: string, scope: CredentialAccessScope): CredentialMetadata | undefined;
20
25
  envExposures(cwd: string | undefined, scope: CredentialAccessScope): Record<string, string>;
21
26
  /** Subscribe to host credential snapshot changes (desktop worker uses this to cancel in-flight Links). */
@@ -48,6 +53,9 @@ export interface CredentialSnapshotEntry {
48
53
  cwd?: string;
49
54
  full: CredentialMetadata[];
50
55
  project: CredentialMetadata[];
56
+ /** Older hosts omit these; their credential-store readability is unknown. */
57
+ readableFull?: boolean;
58
+ readableProject?: boolean;
51
59
  envFull: Record<string, string>;
52
60
  envProject: Record<string, string>;
53
61
  }
@@ -59,7 +67,7 @@ export declare function setDefaultCredentialAccess(access: CredentialAccess | nu
59
67
  export declare function getCredentialAccess(): CredentialAccess;
60
68
  export declare function createIpcCredentialAccess(transport: Pick<Transport, "send" | "onMessage">): CredentialAccess;
61
69
  export declare function credentialAccessScope(scope: SettingsScope | undefined): CredentialAccessScope;
62
- export declare function isCredentialSecretAvailable(secret: string | undefined): secret is string;
70
+ export { isCredentialSecretAvailable } from "./types.js";
63
71
  export declare const localCredentialAccess: CredentialAccess;
64
72
  export declare function materializeCookieSecret(credentialId: string, secret: string): {
65
73
  cookiesFile: string;
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from "node:f
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { credentialAllowsEnvExposure, credentialSecretHint, } from "./types.js";
5
+ import { credentialAllowsEnvExposure, credentialSecretHint, isCredentialSecretAvailable, } from "./types.js";
6
6
  import { CredentialStore } from "./store.js";
7
7
  import { formatNetscapeCookies, parseCookieJar } from "./cookie-jar.js";
8
8
  import { parseOAuthCredentialSecret, isBrowserOAuthLinkCredential, resolveLinkCredentialAccessToken, shouldRefreshOAuthCredential, summarizeOAuthCredentialSecret, } from "./oauth.js";
@@ -77,6 +77,15 @@ export function createIpcCredentialAccess(transport) {
77
77
  return [];
78
78
  return scope === "project" ? cloneMetadata(entry.project) : cloneMetadata(entry.full);
79
79
  },
80
+ listMaskedWithStatus(cwd, scope) {
81
+ const entry = entryFor(cwd);
82
+ if (!entry)
83
+ return { credentials: [], readable: false };
84
+ return {
85
+ credentials: cloneMetadata(scope === "project" ? entry.project : entry.full),
86
+ readable: (scope === "project" ? entry.readableProject : entry.readableFull) === true,
87
+ };
88
+ },
80
89
  resolveMeta(cwd, id, scope) {
81
90
  const entry = entryFor(cwd);
82
91
  if (!entry)
@@ -122,9 +131,7 @@ export function createIpcCredentialAccess(transport) {
122
131
  export function credentialAccessScope(scope) {
123
132
  return scope === "full" || scope === undefined ? "full" : "project";
124
133
  }
125
- export function isCredentialSecretAvailable(secret) {
126
- return typeof secret === "string" && secret.length > 0 && !secret.startsWith("enc:");
127
- }
134
+ export { isCredentialSecretAvailable } from "./types.js";
128
135
  function toMetadata(cred) {
129
136
  const secret = cred.secret;
130
137
  const available = isCredentialSecretAvailable(secret);
@@ -149,6 +156,10 @@ export const localCredentialAccess = {
149
156
  listMasked(cwd, scope) {
150
157
  return storeFor(cwd).list(scope).map(toMetadata);
151
158
  },
159
+ listMaskedWithStatus(cwd, scope) {
160
+ const result = storeFor(cwd).listWithStatus(scope);
161
+ return { credentials: result.credentials.map(toMetadata), readable: result.readable };
162
+ },
152
163
  resolveMeta(cwd, id, scope) {
153
164
  const cred = storeFor(cwd).resolve(id, scope);
154
165
  return cred ? toMetadata(cred) : undefined;
@@ -70,6 +70,12 @@ export declare class CredentialStore {
70
70
  * id 不存在则 no-op。
71
71
  */
72
72
  patch(scope: CredentialScope, id: string, fields: Partial<Pick<Credential, "label" | "exposeAsEnv" | "autoUseByAI" | "autoInjectByAI" | "meta">>): void;
73
+ /**
74
+ * Update an existing record while holding the store lock. The callback sees
75
+ * the latest decrypted record and may return undefined to decline the write.
76
+ * Unlike save(), this never recreates a deleted credential.
77
+ */
78
+ updateExisting(scope: CredentialScope, id: string, update: (current: Credential) => Credential | undefined): boolean;
73
79
  remove(scope: CredentialScope, id: string): void;
74
80
  /**
75
81
  * List credentials visible to an engine of the given settings scope.
@@ -80,6 +86,11 @@ export declare class CredentialStore {
80
86
  * same host-isolation contract as {@link envExposures} and top-level env.
81
87
  */
82
88
  list(scope?: "full" | "project"): Credential[];
89
+ /** Preserve read failures so diagnostics can distinguish unknown state from an empty store. */
90
+ listWithStatus(scope?: "full" | "project"): {
91
+ credentials: Credential[];
92
+ readable: boolean;
93
+ };
83
94
  resolve(id: string, scope?: "full" | "project"): Credential | undefined;
84
95
  /**
85
96
  * Credentials flagged "expose as env var" → a `{ ENV_NAME: secret }` map for
@@ -1,7 +1,7 @@
1
1
  import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
- import { credentialAllowsEnvExposure, credentialSecretHint, } from "./types.js";
4
+ import { credentialAllowsEnvExposure, credentialSecretHint, isCredentialSecretAvailable, } 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";
@@ -329,6 +329,34 @@ export class CredentialStore {
329
329
  return true;
330
330
  });
331
331
  }
332
+ /**
333
+ * Update an existing record while holding the store lock. The callback sees
334
+ * the latest decrypted record and may return undefined to decline the write.
335
+ * Unlike save(), this never recreates a deleted credential.
336
+ */
337
+ updateExisting(scope, id, update) {
338
+ if (typeof id !== "string" || !id || id.length > MAX_CREDENTIAL_ID_CHARS || id.includes("\0")) {
339
+ throw new Error("invalid credential id");
340
+ }
341
+ let changed = false;
342
+ this.mutate(scope, (file) => {
343
+ const idx = file.credentials.findIndex((credential) => credential.id === id);
344
+ if (idx < 0)
345
+ return false;
346
+ const next = update(normalizeCredential(file.credentials[idx], true));
347
+ if (!next)
348
+ return false;
349
+ const normalized = normalizeCredential(next, true);
350
+ if (normalized.id !== id)
351
+ throw new Error("credential update cannot change id");
352
+ file.credentials[idx] = credentialAllowsEnvExposure(normalized.type)
353
+ ? normalized
354
+ : { ...normalized, exposeAsEnv: undefined };
355
+ changed = true;
356
+ return true;
357
+ });
358
+ return changed;
359
+ }
332
360
  remove(scope, id) {
333
361
  if (typeof id !== "string" || !id || id.length > MAX_CREDENTIAL_ID_CHARS || id.includes("\0")) {
334
362
  throw new Error("invalid credential id");
@@ -350,14 +378,20 @@ export class CredentialStore {
350
378
  * same host-isolation contract as {@link envExposures} and top-level env.
351
379
  */
352
380
  list(scope = "full") {
381
+ return this.listWithStatus(scope).credentials;
382
+ }
383
+ /** Preserve read failures so diagnostics can distinguish unknown state from an empty store. */
384
+ listWithStatus(scope = "full") {
353
385
  const byId = new Map();
354
- if (scope === "full") {
355
- for (const c of this.read("user").credentials)
356
- byId.set(c.id, c);
386
+ let readable = true;
387
+ const scopes = scope === "full" ? ["user", "project"] : ["project"];
388
+ for (const layer of scopes) {
389
+ const result = this.readGuarded(layer);
390
+ readable = readable && result.readable;
391
+ for (const credential of result.file.credentials)
392
+ byId.set(credential.id, credential);
357
393
  }
358
- for (const c of this.read("project").credentials)
359
- byId.set(c.id, c); // project wins
360
- return [...byId.values()];
394
+ return { credentials: [...byId.values()], readable };
361
395
  }
362
396
  resolve(id, scope = "full") {
363
397
  return this.list(scope).find((c) => c.id === id);
@@ -397,11 +431,12 @@ export class CredentialStore {
397
431
  listMasked(scope = "full") {
398
432
  return this.list(scope).map((c) => {
399
433
  const { secret, ...rest } = c;
434
+ const available = isCredentialSecretAvailable(secret);
400
435
  return {
401
436
  ...rest,
402
437
  ...(credentialAllowsEnvExposure(c.type) ? {} : { exposeAsEnv: undefined }),
403
- hasSecret: typeof secret === "string" && secret.length > 0,
404
- secretHint: credentialSecretHint(c.type, secret),
438
+ hasSecret: available,
439
+ secretHint: available ? credentialSecretHint(c.type, secret) : undefined,
405
440
  ...(c.type === "oauth" || isBrowserOAuthLinkCredential(c)
406
441
  ? { oauthStatus: summarizeOAuthCredentialSecret(secret) }
407
442
  : {}),
@@ -10,6 +10,8 @@ export type CredentialType = "token" | "link" | "cookie" | "oauth";
10
10
  * values and must remain behind the credential resolver boundary.
11
11
  */
12
12
  export declare function credentialAllowsEnvExposure(type: CredentialType): boolean;
13
+ /** An unreadable encrypted value is preserved on disk, but is not a usable secret. */
14
+ export declare function isCredentialSecretAvailable(secret: string | undefined): secret is string;
13
15
  /** Build a renderer-safe hint without deriving bytes from structured JSON. */
14
16
  export declare function credentialSecretHint(type: CredentialType, secret: string | undefined): string | undefined;
15
17
  export interface OAuthCredentialSecret {
@@ -112,6 +114,8 @@ export interface Credential {
112
114
  domain?: string;
113
115
  scope?: "domain" | "all";
114
116
  switchMode?: "clear" | "merge";
117
+ /** Opt in to writing browser-side cookie rotations back to this saved credential. */
118
+ autoRefreshFromBrowser?: boolean;
115
119
  /** Link provider id for credentials owned by a local/server app connection. */
116
120
  linkProvider?: string;
117
121
  /** Provider-specific connection method, e.g. fine-grained-pat or github-app. */
@@ -6,6 +6,10 @@
6
6
  export function credentialAllowsEnvExposure(type) {
7
7
  return type === "token" || type === "link";
8
8
  }
9
+ /** An unreadable encrypted value is preserved on disk, but is not a usable secret. */
10
+ export function isCredentialSecretAvailable(secret) {
11
+ return typeof secret === "string" && secret.length > 0 && !secret.startsWith("enc:");
12
+ }
9
13
  /** Build a renderer-safe hint without deriving bytes from structured JSON. */
10
14
  export function credentialSecretHint(type, secret) {
11
15
  if (!secret)
@@ -23,7 +23,10 @@ const BASE_DESCRIPTION = "Use a stored credential (token / API key / login cooki
23
23
  "or manually exporting credentials. Before `--cookies-from-browser` or retrying an " +
24
24
  "authentication failure, check stored credentials here. If `Currently available` names a " +
25
25
  "matching id, fetch it directly; otherwise call with NO arguments first to list available " +
26
- "credentials (id + label + type), then call again with `id`. Token/link credentials return their secret " +
26
+ "credentials (id + label + type), then call again with `id`. Provider-owned Link connections " +
27
+ "and existing CLI sessions are not listed here. Call LinkAction with provider only to check " +
28
+ "those before concluding " +
29
+ "a service is disconnected or asking the user to log in. Exposable token credentials return their secret " +
27
30
  "value; cookie credentials are materialized to a temporary Netscape cookies.txt file " +
28
31
  "(use it as `yt-dlp --cookies <cookiesFile>` / `curl -b <cookiesFile>`). " +
29
32
  "Each use is gated by a quick user approval unless auto-approve is on.";
@@ -125,7 +128,14 @@ export async function useCredentialTool(args, ctx) {
125
128
  .listMasked(cwd, scope)
126
129
  .filter(isAgentExposableCredential)
127
130
  .map((c) => ({ id: c.id, label: c.label, type: c.type }));
128
- return json({ kind: "list", credentials });
131
+ return json({
132
+ kind: "list",
133
+ credentials,
134
+ notice: "This list excludes provider-owned Link credentials and CLI login sessions. " +
135
+ 'Call LinkAction with provider only, for example LinkAction({provider: "github"}), ' +
136
+ "to check saved connections and current CLI authentication; " +
137
+ "an absent credential here does not mean the service is signed out.",
138
+ });
129
139
  }
130
140
  const cred = access.resolveMeta(cwd, id, scope);
131
141
  if (!cred) {
@@ -104,7 +104,8 @@ export function createAuthorizedSessionMessageService(options) {
104
104
  const targets = catalog.filter((target) => target.sessionId !== sourceSessionId);
105
105
  return {
106
106
  targets,
107
- send: async ({ targetSessionId, message }) => {
107
+ send: async ({ targetSessionId, message, signal }) => {
108
+ signal?.throwIfAborted();
108
109
  const target = targets.find((candidate) => candidate.sessionId === targetSessionId);
109
110
  if (!target)
110
111
  throw new Error("target Session is not in the host-authorized project list");
@@ -112,8 +113,14 @@ export function createAuthorizedSessionMessageService(options) {
112
113
  throw new Error("message is required");
113
114
  if (message.length > 48_000)
114
115
  throw new Error("message exceeds 48000 characters");
115
- await router({ sourceSessionId, target, message, catalog });
116
- return target;
116
+ const receipt = await router({
117
+ sourceSessionId,
118
+ target,
119
+ message,
120
+ catalog,
121
+ ...(signal ? { signal } : {}),
122
+ });
123
+ return receipt ? { ...target, receipt } : target;
117
124
  },
118
125
  };
119
126
  }
@@ -158,6 +158,8 @@ export declare class Engine {
158
158
  * running goal's turn/budget ceilings mid-run (TODO 3.1). Null when idle.
159
159
  */
160
160
  private activeTurnLoop;
161
+ /** Frozen behavior-profile ceiling for the live run's Goal controls. */
162
+ private activeProfileMaxTurns;
161
163
  /**
162
164
  * The goal-stop hook of the in-flight goal run, exposed so clearGoal() can
163
165
  * unregister it mid-run (the closure holds the now-cleared goal and would
@@ -459,6 +461,7 @@ export declare class Engine {
459
461
  * published through the `publishGoalJudgeContext` callback.
460
462
  */
461
463
  private buildTurnLoop;
464
+ private resolveRunMaxTurns;
462
465
  private buildSummarizeFn;
463
466
  private resolveAuxClient;
464
467
  private runMemoryPipeline;
@@ -232,6 +232,8 @@ export class Engine {
232
232
  * running goal's turn/budget ceilings mid-run (TODO 3.1). Null when idle.
233
233
  */
234
234
  activeTurnLoop = null;
235
+ /** Frozen behavior-profile ceiling for the live run's Goal controls. */
236
+ activeProfileMaxTurns;
235
237
  /**
236
238
  * The goal-stop hook of the in-flight goal run, exposed so clearGoal() can
237
239
  * unregister it mid-run (the closure holds the now-cleared goal and would
@@ -1251,6 +1253,11 @@ export class Engine {
1251
1253
  task,
1252
1254
  cwd,
1253
1255
  options,
1256
+ profileMaxTurns: typeof profile?.maxTurns === "number" &&
1257
+ Number.isSafeInteger(profile.maxTurns) &&
1258
+ profile.maxTurns > 0
1259
+ ? profile.maxTurns
1260
+ : undefined,
1254
1261
  toolCtx,
1255
1262
  toolExecutor,
1256
1263
  contextManager,
@@ -1285,8 +1292,10 @@ export class Engine {
1285
1292
  this.activeRuntimeGoal = null;
1286
1293
  this.activePersistedRunGoal = null;
1287
1294
  }
1288
- if (this.activeTurnLoop === turnLoop)
1295
+ if (this.activeTurnLoop === turnLoop) {
1289
1296
  this.activeTurnLoop = null;
1297
+ this.activeProfileMaxTurns = undefined;
1298
+ }
1290
1299
  if (this.activeRunSession === session)
1291
1300
  this.activeRunSession = null;
1292
1301
  // Run-scoped too: this handler is re-registered every run(), so it must be
@@ -1714,7 +1723,7 @@ export class Engine {
1714
1723
  * phases consume cross back out.
1715
1724
  */
1716
1725
  async wireRunLoop(args) {
1717
- const { session, sid, task, cwd, options, toolCtx, toolExecutor, contextManager, llmClient, auxSummaryClient, fullSystemPrompt, toolDefs, claimClientMessageId, releaseClientMessageId, freshImageMessage, dynamicContextMsg, } = args;
1726
+ const { session, sid, task, cwd, options, profileMaxTurns, toolCtx, toolExecutor, contextManager, llmClient, auxSummaryClient, fullSystemPrompt, toolDefs, claimClientMessageId, releaseClientMessageId, freshImageMessage, dynamicContextMsg, } = args;
1718
1727
  // eslint-disable-next-line prefer-const
1719
1728
  let turnLoop;
1720
1729
  const accounting = createRunUsageAccounting({
@@ -1827,6 +1836,7 @@ export class Engine {
1827
1836
  toolDefs,
1828
1837
  sid,
1829
1838
  options,
1839
+ profileMaxTurns,
1830
1840
  cwd,
1831
1841
  claimClientMessageId,
1832
1842
  releaseClientMessageId,
@@ -1846,8 +1856,10 @@ export class Engine {
1846
1856
  toolCtx.recordBilledUsage = recordExternalBilledUsage;
1847
1857
  // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1848
1858
  // a sub-agent's loop is its own concern and isn't user-extendable.
1849
- if (this.config.isSubAgent !== true)
1859
+ if (this.config.isSubAgent !== true) {
1850
1860
  this.activeTurnLoop = turnLoop;
1861
+ this.activeProfileMaxTurns = profileMaxTurns;
1862
+ }
1851
1863
  // Expose this run's session bundle so a mid-run clearGoal() wipes the goal
1852
1864
  // on the very instance this loop keeps saving (see field doc). Top-level
1853
1865
  // only — sub-agents don't carry user-clearable persistent goals.
@@ -2082,7 +2094,7 @@ export class Engine {
2082
2094
  * published through the `publishGoalJudgeContext` callback.
2083
2095
  */
2084
2096
  buildTurnLoop(args) {
2085
- const { modelFacade, toolExecutor, contextManager, session, fullSystemPrompt, toolDefs, sid, options, cwd, claimClientMessageId, releaseClientMessageId, toolCtx, persistedRunGoal, goalHookHandler, normalizedGoal, freshImageMessage, dynamicContextMsg, usageBaseline, getRunUsage, recordCumulativeUsage, publishGoalJudgeContext, } = args;
2097
+ const { modelFacade, toolExecutor, contextManager, session, fullSystemPrompt, toolDefs, sid, options, profileMaxTurns, cwd, claimClientMessageId, releaseClientMessageId, toolCtx, persistedRunGoal, goalHookHandler, normalizedGoal, freshImageMessage, dynamicContextMsg, usageBaseline, getRunUsage, recordCumulativeUsage, publishGoalJudgeContext, } = args;
2086
2098
  // Surface compaction events to the UI so the user knows when context was trimmed.
2087
2099
  // Buffer the most recent event so TurnLoop can drain it and emit the
2088
2100
  // post_compact hook on the next turn (ContextManager itself doesn't
@@ -2146,6 +2158,9 @@ export class Engine {
2146
2158
  releaseClientMessageId,
2147
2159
  setOriginClientMessageId: (clientMessageId) => {
2148
2160
  toolCtx.originClientMessageId = clientMessageId;
2161
+ const profileId = toolCtx.toolVisibility?.behaviorProfile;
2162
+ const profile = profileId ? this.behaviorProfiles.get(profileId) : undefined;
2163
+ profile?.onUserInputChanged?.(toolCtx.runScopedServices ?? {});
2149
2164
  },
2150
2165
  recordCumulativeUsage,
2151
2166
  onAgentUsage: (usage) => options?.onAgentProgress?.({ type: "usage", usage }),
@@ -2199,7 +2214,7 @@ export class Engine {
2199
2214
  // getting re-blocked by the stop-hook until it's done, and the 100
2200
2215
  // interactive default would silently truncate a long objective. The
2201
2216
  // real backstops are the goal token/time budgets + maxStopBlocks.
2202
- maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
2217
+ maxTurns: this.resolveRunMaxTurns(normalizedGoal, profileMaxTurns),
2203
2218
  // Consecutive stop-block cap: config override > goal.maxStopBlocks >
2204
2219
  // GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
2205
2220
  // for complex goals that legitimately get re-blocked while advancing.
@@ -2253,6 +2268,10 @@ export class Engine {
2253
2268
  });
2254
2269
  return turnLoop;
2255
2270
  }
2271
+ resolveRunMaxTurns(goal, profileMaxTurns) {
2272
+ const resolved = resolveMaxTurns(this.config.maxTurns, goal);
2273
+ return profileMaxTurns === undefined ? resolved : Math.min(resolved, profileMaxTurns);
2274
+ }
2256
2275
  buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2257
2276
  return this.auxiliaryPipeline.buildSummarizeFn(auxSummaryClient, recordCumulativeUsage);
2258
2277
  }
@@ -2717,7 +2736,7 @@ export class Engine {
2717
2736
  : resumed
2718
2737
  ? `目标已恢复:${next.objective}`
2719
2738
  : undefined, {
2720
- maxTurns: resolveMaxTurns(this.config.maxTurns, next),
2739
+ maxTurns: this.resolveRunMaxTurns(next, this.activeProfileMaxTurns),
2721
2740
  maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, next),
2722
2741
  });
2723
2742
  if (this.activeGoalHook && !this.activeGoalHookAttached) {
@@ -3134,6 +3153,19 @@ export class Engine {
3134
3153
  extendGoalRun(opts) {
3135
3154
  if (!this.activeTurnLoop)
3136
3155
  return null;
3156
+ if (this.activeProfileMaxTurns !== undefined &&
3157
+ typeof opts.addTurns === "number" &&
3158
+ Number.isFinite(opts.addTurns) &&
3159
+ opts.addTurns > 0 &&
3160
+ opts.addTurns <= Number.MAX_SAFE_INTEGER) {
3161
+ // An empty extension is a no-op that returns the live ceilings. Goal
3162
+ // budgets may still grow, but a behavior-profile ceiling cannot.
3163
+ const currentMaxTurns = this.activeTurnLoop.extend({}).maxTurns;
3164
+ return this.activeTurnLoop.extend({
3165
+ ...opts,
3166
+ addTurns: Math.min(opts.addTurns, this.activeProfileMaxTurns - currentMaxTurns),
3167
+ });
3168
+ }
3137
3169
  return this.activeTurnLoop.extend(opts);
3138
3170
  }
3139
3171
  /**
@@ -4,6 +4,12 @@ const DEFAULT_MAX_SESSIONS = 256;
4
4
  const DROP_MIN_PREVIOUS_TOKENS = 100;
5
5
  const DROP_MAX_CURRENT_TOKENS = 64;
6
6
  const DROP_RATIO = 0.1;
7
+ // Partial cache invalidation can leave thousands of tokens cached. Require
8
+ // both a halving and a meaningful absolute loss to avoid reporting small
9
+ // fluctuations. Compare cache reads, not hit rates: appending uncached input
10
+ // can lower the hit rate while the reusable prefix remains fully cached.
11
+ const PARTIAL_DROP_MAX_RETAINED_RATIO = 0.5;
12
+ const PARTIAL_DROP_MIN_LOST_TOKENS = 4096;
7
13
  /**
8
14
  * Session stickiness audit. Semantic, capability, and security switches must
9
15
  * take effect on the next request even when they invalidate a cache prefix.
@@ -150,7 +156,10 @@ export class PromptCacheDiagnosticRecorder {
150
156
  if (previous.cacheReadTokens < DROP_MIN_PREVIOUS_TOKENS)
151
157
  return { kind: "updated" };
152
158
  const dropRatio = previous.cacheReadTokens > 0 ? cacheReadTokens / previous.cacheReadTokens : 1;
153
- if (cacheReadTokens > DROP_MAX_CURRENT_TOKENS || dropRatio > DROP_RATIO) {
159
+ const nearColdDrop = cacheReadTokens <= DROP_MAX_CURRENT_TOKENS && dropRatio <= DROP_RATIO;
160
+ const partialDrop = dropRatio <= PARTIAL_DROP_MAX_RETAINED_RATIO &&
161
+ previous.cacheReadTokens - cacheReadTokens >= PARTIAL_DROP_MIN_LOST_TOKENS;
162
+ if (!nearColdDrop && !partialDrop) {
154
163
  return { kind: "updated" };
155
164
  }
156
165
  return {
@@ -10,9 +10,13 @@ import { logger } from "../logging/logger.js";
10
10
  /** engine.ts:explicit/stored/fallback 归一化 + 持久化 + goal_set 事件。 */
11
11
  export function resolveRunGoal(args) {
12
12
  const { options, session, sessionManager, configGoal, isSubAgent, sid, onStream } = args;
13
- const explicitGoal = normalizeGoal(options?.goal);
13
+ const goalDisabled = options?.disableGoal === true;
14
+ const explicitGoal = goalDisabled ? undefined : normalizeGoal(options?.goal);
14
15
  const storedLifecycle = session.state.goalLifecycle;
15
- const storedGoal = isSubAgent !== true && storedLifecycle && isGoalLifecycleCurrent(storedLifecycle)
16
+ const storedGoal = !goalDisabled &&
17
+ isSubAgent !== true &&
18
+ storedLifecycle &&
19
+ isGoalLifecycleCurrent(storedLifecycle)
16
20
  ? goalConfigFromLifecycle(storedLifecycle)
17
21
  : undefined;
18
22
  if (storedGoal &&
@@ -48,7 +52,7 @@ export function resolveRunGoal(args) {
48
52
  replaced,
49
53
  });
50
54
  }
51
- const fallbackGoal = normalizeGoal(configGoal);
55
+ const fallbackGoal = goalDisabled ? undefined : normalizeGoal(configGoal);
52
56
  if (fallbackGoal && !fallbackGoal.goalId)
53
57
  fallbackGoal.goalId = randomUUID();
54
58
  if (fallbackGoal && !fallbackGoal.revision)
@@ -26,6 +26,13 @@ export interface RunBehaviorProfile {
26
26
  systemPromptAppend?: string;
27
27
  /** Hard tool allowlist for the run (model visibility + execution gate). */
28
28
  allowedToolNames?: ReadonlySet<string>;
29
+ /**
30
+ * Positive safe-integer ceiling on model/tool turns. Clamps the existing
31
+ * resolved config/Goal limit, including during live Goal edits/extensions.
32
+ * Invalid values are ignored. The existing no-tools final summary may run
33
+ * once after this ceiling is reached.
34
+ */
35
+ maxTurns?: number;
29
36
  /** When set, the run's permission mode is locked to this value. */
30
37
  forcePermissionMode?: NonNullable<EngineConfig["permissionMode"]>;
31
38
  /** When true, per-run planMode requests are ignored. */
@@ -70,6 +77,13 @@ export interface RunBehaviorProfile {
70
77
  profileParams: Readonly<Record<string, unknown>>;
71
78
  reportResult: (key: string, value: unknown) => void;
72
79
  }) => Record<string, unknown>;
80
+ /**
81
+ * Called with this run's services after each new steered user message is
82
+ * persisted, before its injection event and the next model step. Profiles
83
+ * may invalidate deferred drafts that answered an earlier input revision.
84
+ * Initial input, queued/revoked steers and duplicate messages do not call it.
85
+ */
86
+ onUserInputChanged?: (services: Record<string, unknown>) => void;
73
87
  /**
74
88
  * Per-run metadata exposed to builtin availability guards / definition
75
89
  * rewriters via ToolVisibilityContext.profileMeta.
@@ -98,6 +112,8 @@ export interface EngineRunOptions {
98
112
  planMode?: boolean;
99
113
  approvalRouter?: ApprovalRouter;
100
114
  goal?: string | GoalConfig;
115
+ /** Run this standalone turn without explicit, persisted, or configured Goal mode. */
116
+ disableGoal?: boolean;
101
117
  injected?: boolean;
102
118
  clientMessageId?: string;
103
119
  /**
@@ -54,30 +54,12 @@ export async function resolveRunWorkspace(args) {
54
54
  ? await args.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
55
55
  : undefined;
56
56
  if (workspaceResume && !workspaceResume.ok) {
57
- return {
58
- ok: false,
59
- result: {
60
- text: `ERROR: ${workspaceResume.message}`,
61
- reason: "completed",
62
- sessionId: options.sessionId,
63
- turnCount: 0,
64
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
65
- },
66
- };
57
+ return workspaceError(options?.sessionId, workspaceResume.message);
67
58
  }
68
59
  if (workspaceResume?.ok &&
69
60
  workspaceResume.reason === "worktree_missing_branch_gone" &&
70
61
  workspaceResume.message) {
71
- return {
72
- ok: false,
73
- result: {
74
- text: workspaceResume.message,
75
- reason: "completed",
76
- sessionId: options.sessionId,
77
- turnCount: 0,
78
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
79
- },
80
- };
62
+ return workspaceError(options?.sessionId, workspaceResume.message);
81
63
  }
82
64
  // Existing P1 sessions resolve cwd from SessionWorkspace, even if the host
83
65
  // passes a stale cwd. Legacy sessions without workspace keep the historical
@@ -158,7 +140,9 @@ function workspaceError(sessionId, message) {
158
140
  ok: false,
159
141
  result: {
160
142
  text: `ERROR: ${message}`,
161
- reason: "completed",
143
+ // Like other initialization failures, this run never reached a model
144
+ // turn. Hosts must see a failure rather than report a completed delivery.
145
+ reason: "model_error",
162
146
  sessionId: sessionId ?? "workspace-invalid",
163
147
  turnCount: 0,
164
148
  usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
@@ -289,10 +289,10 @@ export declare class TurnLoop {
289
289
  private stripVolatileContextMessages;
290
290
  /**
291
291
  * Keep volatile context out of compaction/summarization without moving it on
292
- * every model round. If context management is a no-op, return the original
293
- * array so the provider sees a strictly append-only prompt. A real rewrite
294
- * (dedupe/compaction/truncation) already invalidates the old prefix, so start
295
- * a fresh append-only segment with the volatile snapshot at the new tail.
292
+ * every model round. Normalizing a newly appended tool result must not move
293
+ * an earlier volatile snapshot and invalidate an otherwise unchanged prefix.
294
+ * Keep its position when the preceding stable messages remain intact; reset
295
+ * to the tail when that boundary was rewritten or compaction changed length.
296
296
  */
297
297
  private restoreVolatileAfterContextManagement;
298
298
  private manageContextMessages;
@@ -342,16 +342,36 @@ export class TurnLoop {
342
342
  }
343
343
  /**
344
344
  * Keep volatile context out of compaction/summarization without moving it on
345
- * every model round. If context management is a no-op, return the original
346
- * array so the provider sees a strictly append-only prompt. A real rewrite
347
- * (dedupe/compaction/truncation) already invalidates the old prefix, so start
348
- * a fresh append-only segment with the volatile snapshot at the new tail.
345
+ * every model round. Normalizing a newly appended tool result must not move
346
+ * an earlier volatile snapshot and invalidate an otherwise unchanged prefix.
347
+ * Keep its position when the preceding stable messages remain intact; reset
348
+ * to the tail when that boundary was rewritten or compaction changed length.
349
349
  */
350
350
  restoreVolatileAfterContextManagement(original, stableInput, managedStable) {
351
- const unchanged = stableInput.length === managedStable.length &&
352
- stableInput.every((message, index) => managedStable[index] === message);
353
- if (unchanged)
354
- return original;
351
+ if (stableInput.length === managedStable.length) {
352
+ // Some cleanup passes copy messages even when their final content is
353
+ // unchanged. Object identity is only a fast path, not a cache boundary.
354
+ const firstChangedIndex = stableInput.findIndex((message, index) => managedStable[index] !== message &&
355
+ JSON.stringify(managedStable[index]) !== JSON.stringify(message));
356
+ if (firstChangedIndex === -1)
357
+ return original;
358
+ let stableIndex = 0;
359
+ let lastVolatileBoundary = 0;
360
+ for (const message of original) {
361
+ if (this.volatileContextMessages.has(message)) {
362
+ lastVolatileBoundary = stableIndex;
363
+ }
364
+ else {
365
+ stableIndex++;
366
+ }
367
+ }
368
+ if (firstChangedIndex >= lastVolatileBoundary) {
369
+ // Apply the managed tail in place, including its persisted/truncated
370
+ // results. Never restore old payloads just to preserve cache hits.
371
+ stableIndex = 0;
372
+ return original.map((message) => this.volatileContextMessages.has(message) ? message : managedStable[stableIndex++]);
373
+ }
374
+ }
355
375
  const volatile = original.filter((message) => this.volatileContextMessages.has(message));
356
376
  return [...managedStable, ...volatile];
357
377
  }
@@ -1754,11 +1774,11 @@ export class TurnLoop {
1754
1774
  break;
1755
1775
  }
1756
1776
  consumed = true;
1757
- this.deps.setOriginClientMessageId?.(clientMessageId);
1758
1777
  const message = { role: "user", content };
1759
1778
  messages.push(message);
1760
1779
  this.trackFreshImageMessage(message);
1761
1780
  this.deps.transcript.appendMessage("user", content, { steerId: id, clientMessageId });
1781
+ this.deps.setOriginClientMessageId?.(clientMessageId);
1762
1782
  this.config.onStream?.({ type: "steer_injected", text, id });
1763
1783
  }
1764
1784
  return consumed;