@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.0

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 (57) hide show
  1. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  2. package/dist/cc-orchestrator/agent-adapter.js +4 -0
  3. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  4. package/dist/cc-orchestrator/codex-session-history.js +64 -4
  5. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  6. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
  7. package/dist/cc-orchestrator/external-agent-driver.js +202 -38
  8. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  9. package/dist/cc-orchestrator/session-history.js +96 -13
  10. package/dist/credentials/access.d.ts +1 -0
  11. package/dist/credentials/access.js +2 -0
  12. package/dist/credentials/index.d.ts +2 -1
  13. package/dist/credentials/index.js +1 -0
  14. package/dist/credentials/oauth.d.ts +20 -0
  15. package/dist/credentials/oauth.js +114 -0
  16. package/dist/credentials/store.d.ts +1 -0
  17. package/dist/credentials/store.js +3 -1
  18. package/dist/credentials/types.d.ts +47 -1
  19. package/dist/engine/engine.d.ts +6 -2
  20. package/dist/engine/engine.js +159 -192
  21. package/dist/engine/goal.d.ts +17 -0
  22. package/dist/engine/goal.js +16 -6
  23. package/dist/engine/input-attachments.js +156 -13
  24. package/dist/engine/run-image-input.d.ts +22 -0
  25. package/dist/engine/run-image-input.js +195 -0
  26. package/dist/engine/steer-queue.d.ts +3 -1
  27. package/dist/engine/steer-queue.js +10 -2
  28. package/dist/engine/turn-loop.d.ts +30 -1
  29. package/dist/engine/turn-loop.js +112 -17
  30. package/dist/hooks/goal-stop-hook.d.ts +33 -1
  31. package/dist/hooks/goal-stop-hook.js +202 -34
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/preset/index.js +14 -4
  35. package/dist/protocol/server.js +1 -1
  36. package/dist/protocol/types.d.ts +2 -0
  37. package/dist/session/session-manager.js +34 -1
  38. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  39. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  40. package/dist/tool-system/builtin/background-jobs.d.ts +25 -5
  41. package/dist/tool-system/builtin/background-jobs.js +105 -7
  42. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  43. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  44. package/dist/tool-system/builtin/cron.d.ts +1 -2
  45. package/dist/tool-system/builtin/cron.js +9 -7
  46. package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
  47. package/dist/tool-system/builtin/drive-claude-code.js +307 -20
  48. package/dist/tool-system/builtin/index.js +15 -3
  49. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  50. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  51. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  52. package/dist/tool-system/builtin/sleep.js +1 -22
  53. package/dist/tool-system/context.d.ts +18 -0
  54. package/dist/tool-system/mcp-manager.d.ts +14 -2
  55. package/dist/tool-system/mcp-manager.js +56 -7
  56. package/dist/types.d.ts +23 -7
  57. package/package.json +1 -1
@@ -23,7 +23,7 @@ import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, Int
23
23
  import { HookRegistry } from "../hooks/registry.js";
24
24
  import { wrapHookMessages } from "../hooks/inject.js";
25
25
  import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
26
- import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, } from "./goal.js";
26
+ import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, isSameGoalInstance, } from "./goal.js";
27
27
  import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
28
28
  import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
29
29
  import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
@@ -53,16 +53,16 @@ import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
53
53
  import { defaultCacheDir } from "../llm/model-cache.js";
54
54
  import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
55
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
56
- import { parseTaskWithImages } from "./parse-task.js";
57
- import { buildInputAttachmentContext } from "./input-attachments.js";
58
- import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
59
- import { tryCompressImages } from "./image-compression.js";
60
56
  import { buildSessionTitle } from "./session-title.js";
61
- import { capabilitiesFor } from "../llm/capabilities/index.js";
62
57
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
63
58
  import { runDreamConsolidation } from "../services/dream-consolidation.js";
64
- import { join, isAbsolute } from "node:path";
59
+ import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
60
+ import { join } from "node:path";
65
61
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
62
+ const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
63
+ const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
64
+ const CACHE_READ_DROP_RATIO = 0.1;
65
+ const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
66
66
  /**
67
67
  * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
68
68
  * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
@@ -266,6 +266,7 @@ export class Engine {
266
266
  * LLM response arrives.
267
267
  */
268
268
  ctxOverheadBySid = new Map();
269
+ lastCacheReadBySid = new Map();
269
270
  /**
270
271
  * Step-gap steering queue (per sessionId, in-memory). Host pushes user
271
272
  * messages here via enqueueSteer while a run is in flight; the turn loop
@@ -654,7 +655,7 @@ export class Engine {
654
655
  * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
655
656
  * entry. A blank id is tolerated but means the entry can't be revoked.
656
657
  */
657
- enqueueSteer(sessionId, text, id = "", clientMessageId) {
658
+ enqueueSteer(sessionId, text, id = "", clientMessageId, attachments) {
658
659
  const q = this.steerQueueBySid.get(sessionId) ?? [];
659
660
  const entryId = id || `steer-${q.length}`;
660
661
  if (!sessionId)
@@ -666,12 +667,13 @@ export class Engine {
666
667
  sessionId,
667
668
  id: entryId,
668
669
  clientMessageId,
670
+ attachmentCount: attachments?.length ?? 0,
669
671
  activeRunSessionId: activeRunSessionId ?? null,
670
672
  queueLength: q.length,
671
673
  });
672
674
  return { accepted: false, id: entryId };
673
675
  }
674
- const next = enqueueSteerItem(q, entryId, text, clientMessageId);
676
+ const next = enqueueSteerItem(q, entryId, text, clientMessageId, attachments);
675
677
  if (next === q)
676
678
  return { accepted: false, id: entryId }; // blank text dropped
677
679
  this.steerQueueBySid.set(sessionId, next);
@@ -679,6 +681,7 @@ export class Engine {
679
681
  sessionId,
680
682
  id: entryId,
681
683
  clientMessageId,
684
+ attachmentCount: attachments?.length ?? 0,
682
685
  activeRunSessionId,
683
686
  queueLength: next.length,
684
687
  });
@@ -715,6 +718,13 @@ export class Engine {
715
718
  });
716
719
  return drained;
717
720
  }
721
+ /** Put failed steer preparation back ahead of messages queued while it was being prepared. */
722
+ restoreSteer(sessionId, items) {
723
+ if (items.length === 0)
724
+ return;
725
+ const queued = this.steerQueueBySid.get(sessionId) ?? [];
726
+ this.steerQueueBySid.set(sessionId, [...items, ...queued]);
727
+ }
718
728
  /** Wire the cookie→browser injection callback (InjectCredential tool). Same
719
729
  * post-construction injection model as setBrowserBridge. */
720
730
  setInjectCredential(fn) {
@@ -807,139 +817,16 @@ export class Engine {
807
817
  };
808
818
  if (options)
809
819
  options.onStream = wrappedOnStream;
810
- // ── P2-6: image input ─────────────────────────────────────────────
811
- // Parse `<codeshell-image>` blocks out of the raw task string before
812
- // any other gate looks at it. Two concerns:
813
- // 1. The noise detector below sees the raw base64 as gibberish and
814
- // would reject the whole turn — split images out first so it
815
- // only inspects the prose portion.
816
- // 2. Models that don't accept vision must be refused immediately,
817
- // with the image bytes intact for the user to retry on another
818
- // model. Silent text-only fallback was the failure mode this
819
- // gate is here to prevent.
820
- let parsedTask;
821
- try {
822
- parsedTask = parseTaskWithImages(task);
823
- }
824
- catch (err) {
825
- const msg = err.message;
826
- logger.warn("engine.run.image_parse_failed", { error: msg });
827
- return {
828
- text: `ERROR: image attachment is malformed (${msg}). Drop the image and try again, or re-attach it.`,
829
- reason: "image_error",
830
- sessionId: options?.sessionId ?? "image-parse-failed",
831
- turnCount: 0,
832
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
833
- };
834
- }
835
- const cap = capabilitiesFor((this.config.llm.providerKind ?? this.config.llm.provider), this.config.llm.model);
836
- const attachmentContext = await buildInputAttachmentContext(options?.attachments, cwd, {
837
- includeImageBytes: cap.supportsVision,
838
- expectedSessionId: options?.sessionId,
820
+ const imageInput = await prepareRunImageInput({
821
+ task,
822
+ cwd,
823
+ llm: this.config.llm,
824
+ sessionId: options?.sessionId,
825
+ attachments: options?.attachments,
839
826
  });
840
- if (attachmentContext.errors.length > 0) {
841
- const detail = attachmentContext.errors.join("; ");
842
- logger.warn("engine.run.input_attachment_failed", { error: detail });
843
- return {
844
- text: `ERROR: input attachment could not be read (${detail}). Re-attach it or choose a path inside the workspace.`,
845
- reason: "image_error",
846
- sessionId: options?.sessionId ?? "input-attachment-failed",
847
- turnCount: 0,
848
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
849
- };
850
- }
851
- if (attachmentContext.text || attachmentContext.hasStructuredImageAttachments) {
852
- parsedTask = {
853
- text: [parsedTask.text, attachmentContext.text].filter(Boolean).join("\n\n"),
854
- images: [...parsedTask.images, ...attachmentContext.images],
855
- hasImages: parsedTask.hasImages ||
856
- attachmentContext.images.length > 0 ||
857
- attachmentContext.hasStructuredImageAttachments,
858
- };
859
- }
860
- if (parsedTask.hasImages) {
861
- if (!cap.supportsVision) {
862
- logger.warn("engine.run.vision_not_supported", {
863
- provider: this.config.llm.provider,
864
- model: this.config.llm.model,
865
- imageCount: parsedTask.images.length,
866
- });
867
- return {
868
- text: `ERROR: model "${this.config.llm.model}" does not accept image input. ` +
869
- `Switch to a vision-capable model (e.g. gpt-4o, claude-sonnet, gemini-1.5-pro) and resend.`,
870
- reason: "image_error",
871
- sessionId: options?.sessionId ?? "vision-not-supported",
872
- turnCount: 0,
873
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
874
- };
875
- }
876
- // Size gate. Hosts (desktop renderer, TUI) are expected to
877
- // pre-compress to IMAGE_TARGETS — if they didn't, we fail the turn
878
- // fast with a clear message instead of letting the OpenAI client
879
- // grind through three 16-second "Connection error" retries on a
880
- // 4 MB body. See `image-policy.ts` for the rationale and limits.
881
- let verdict = enforceImagePolicy(parsedTask.images);
882
- if (!verdict.ok && verdict.code === "image_too_large") {
883
- // One image blew the per-image cap. Try the engine-side
884
- // compressor (jimp-backed when installed; no-op otherwise) so
885
- // TUI / MCP paths that lack a host-side resize don't fail
886
- // outright on a screenshot they could have rescaled. The
887
- // re-check below is what decides whether we proceed.
888
- const compressed = await tryCompressImages(parsedTask.images);
889
- if (compressed.anyCompressed) {
890
- parsedTask.images = compressed.images;
891
- logger.info("engine.run.image_compressed", {
892
- before: verdict.offender?.bytes,
893
- after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
894
- });
895
- verdict = enforceImagePolicy(parsedTask.images);
896
- }
897
- }
898
- // After compression, anything still over the per-image cap is
899
- // dropped with a textual placeholder instead of failing the
900
- // turn (TODO-week.md #9e). The "5MB brick session" failure
901
- // mode from Claude Code (research doc §A) was the case where a
902
- // poisoned image entered history and every subsequent request
903
- // re-sent it; placeholders keep history clean while letting
904
- // the rest of the turn run.
905
- if (!verdict.ok && verdict.code === "image_too_large") {
906
- const drop = dropOversizedImages(parsedTask.images);
907
- if (drop.droppedCount > 0) {
908
- parsedTask.images = drop.kept;
909
- parsedTask.hasImages = drop.kept.length > 0;
910
- parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
911
- logger.warn("engine.run.image_dropped", {
912
- droppedCount: drop.droppedCount,
913
- keptCount: drop.kept.length,
914
- });
915
- verdict = enforceImagePolicy(parsedTask.images);
916
- }
917
- }
918
- if (!verdict.ok) {
919
- // Cumulative / count caps can't be rescued by per-image
920
- // dropping (well — too_many_images could trim by FIFO, but
921
- // that's a bigger UX call than we want to make silently).
922
- // Refuse the turn with the policy message.
923
- logger.warn("engine.run.image_policy_failed", {
924
- code: verdict.code,
925
- imageCount: verdict.totals.imageCount,
926
- totalBytes: verdict.totals.totalBytes,
927
- offender: verdict.offender,
928
- });
929
- return {
930
- text: `ERROR: ${verdict.message}`,
931
- reason: "image_error",
932
- sessionId: options?.sessionId ?? `image-policy-${verdict.code}`,
933
- turnCount: 0,
934
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
935
- };
936
- }
937
- }
938
- // For downstream noise-detection + transcript persistence we want the
939
- // *text* portion only — base64 bytes count as "noise" by the heuristic
940
- // and would also bloat the transcript by megabytes per image. Image
941
- // bytes ride in parsedTask.images and re-enter the message tree below.
942
- const taskText = parsedTask.text;
827
+ if (!imageInput.ok)
828
+ return imageInput.result;
829
+ const { parsedTask, taskText } = imageInput;
943
830
  const noise = detectPastedNoise(taskText);
944
831
  if (noise.isNoise) {
945
832
  const hint = `Your input looks like pasted terminal output (${noise.reason}). ` +
@@ -1128,40 +1015,7 @@ export class Engine {
1128
1015
  preset: this.preset.name,
1129
1016
  imageCount: parsedTask.images.length,
1130
1017
  });
1131
- // Compose the user-turn payload once so resume + cold paths agree on
1132
- // shape. With images, content becomes a ContentBlock[] holding one
1133
- // text block (when prose is present) followed by one image block per
1134
- // attachment — the provider-specific clients translate this to OpenAI
1135
- // `image_url` or Anthropic `{type:image, source:base64}` downstream.
1136
- // When an attached image came from a workspace FILE (the desktop composer's
1137
- // path-attach flow sets ParsedImage.name = the absolute path), surface that
1138
- // path to the model as text. The image bytes still ride along for vision,
1139
- // but tools that operate on files — GenerateImage(referenceImages),
1140
- // Read, etc. — need the on-disk path, not just the pixels. Without this the
1141
- // path the composer already knew was silently dropped, and the model would
1142
- // answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
1143
- // Only names that resolve to an existing file qualify; a pasted screenshot
1144
- // whose name is just "screenshot.png" is not a path and is left out.
1145
- const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
1146
- const pathHint = attachedPaths.length > 0
1147
- ? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
1148
- `(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
1149
- : "";
1150
- const userMessageContent = parsedTask.hasImages
1151
- ? [
1152
- ...(parsedTask.text || pathHint
1153
- ? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
1154
- : []),
1155
- ...parsedTask.images.map((img) => ({
1156
- type: "image",
1157
- source: {
1158
- type: "base64",
1159
- media_type: img.mime,
1160
- data: img.base64,
1161
- },
1162
- })),
1163
- ]
1164
- : taskText;
1018
+ const userMessageContent = buildRunUserMessageContent(parsedTask, cwd, taskText);
1165
1019
  // Create or resume session.
1166
1020
  //
1167
1021
  // Three valid shapes:
@@ -1284,8 +1138,14 @@ export class Engine {
1284
1138
  // notifications) attribute to the right session. toolCtx is created
1285
1139
  // before the session bundle is resolved (see ~line 635), so this is
1286
1140
  // the first point we can set it. After this assignment treat the
1287
- // field as immutable for the rest of the run.
1141
+ // field follows the latest successfully injected user intent for the rest
1142
+ // of the run, so tools launched after a steer attribute their side effects
1143
+ // to that steer rather than this original submit.
1288
1144
  toolCtx.sessionId = session.state.sessionId;
1145
+ toolCtx.originClientMessageId = options?.clientMessageId;
1146
+ toolCtx.recordExternalFileChanges = (record) => {
1147
+ session.transcript.append("external_file_changes", { ...record });
1148
+ };
1289
1149
  toolCtx.setSessionWorkspace = (workspace) => {
1290
1150
  session.state.workspace = workspace;
1291
1151
  };
@@ -1724,17 +1584,20 @@ export class Engine {
1724
1584
  // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1725
1585
  // turn loop keeps going until the session model judges the goal met.
1726
1586
  // Registered per-run (and cleared in `finally`) so a later goal-less
1727
- // send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
1728
- // the same cheap aux model used for summarize/compaction — not the
1729
- // (potentially expensive) session model: "is this goal met?" is a classic
1730
- // aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
1731
- // times.
1587
+ // send doesn't inherit a stale goal. The judge runs on the primary
1588
+ // session client; auxSummaryClient remains dedicated to low-consequence
1589
+ // summaries/titles and retains defaults.auxText routing/fallback behavior.
1732
1590
  // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1733
1591
  // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1734
1592
  // when there's effectively no goal (empty objective).
1735
1593
  //
1736
1594
  // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1737
- // later sends and manual interrupts until met or cleared. Resolution:
1595
+ // later sends and manual interrupts until met or cleared. Goal completion
1596
+ // is a high-consequence decision, so V1 routes it to the primary session
1597
+ // client, which is the model expected to interpret the supplied execution
1598
+ // evidence. defaults.auxText remains in force for summaries, titles and
1599
+ // other auxiliary work through auxSummaryClient.
1600
+ // Resolution:
1738
1601
  // 1. options.goal — this send explicitly sets/replaces the goal.
1739
1602
  // 2. session.state.activeGoal — a goal set on an earlier send.
1740
1603
  // 3. config.goal — engine-level default (rare; e.g. headless).
@@ -1743,7 +1606,15 @@ export class Engine {
1743
1606
  // bare send with no options.goal inherits the stored active goal so the
1744
1607
  // model keeps working toward it — that's what makes it persistent.
1745
1608
  const explicitGoal = normalizeGoal(options?.goal);
1746
- const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1609
+ let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1610
+ // Defense in depth: a stale whole-state writer may have restored the
1611
+ // activeGoal field after this exact goal instance was force-terminated.
1612
+ // Refuse to arm it and converge the live bundle before hook registration.
1613
+ if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
1614
+ session.state.activeGoal = undefined;
1615
+ storedGoal = undefined;
1616
+ this.sessionManager.saveState(session.state);
1617
+ }
1747
1618
  if (explicitGoal && this.config.isSubAgent !== true) {
1748
1619
  const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
1749
1620
  // Stamp WHEN this goal was set so the judge can anchor relative deadlines
@@ -1752,7 +1623,14 @@ export class Engine {
1752
1623
  // or changed objective gets a fresh stamp; re-sending the SAME objective
1753
1624
  // keeps the original anchor (the goal continues, the user didn't restate a
1754
1625
  // new deadline). User input never carries setAtMs, so we set it here.
1755
- explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1626
+ const resolvedSetAt = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1627
+ // A user explicitly re-starting the same objective creates a new goal
1628
+ // instance. Avoid a same-millisecond collision with its old tombstone.
1629
+ explicitGoal.setAtMs =
1630
+ session.state.goalTerminal?.objective === explicitGoal.objective &&
1631
+ session.state.goalTerminal.setAtMs === resolvedSetAt
1632
+ ? resolvedSetAt + 1
1633
+ : resolvedSetAt;
1756
1634
  session.state.activeGoal = explicitGoal;
1757
1635
  this.sessionManager.saveState(session.state);
1758
1636
  options?.onStream?.({
@@ -1762,18 +1640,27 @@ export class Engine {
1762
1640
  });
1763
1641
  }
1764
1642
  const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
1643
+ // Snapshot the persisted goal identity owned by THIS run. Terminal
1644
+ // cleanup compares against this immutable copy so an old run cannot
1645
+ // delete a replacement goal installed while it was finishing.
1646
+ const persistedRunGoal = normalizedGoal && isSameGoalInstance(session.state.activeGoal, normalizedGoal)
1647
+ ? { ...normalizedGoal }
1648
+ : undefined;
1765
1649
  let goalHookHandler = null;
1650
+ let goalJudgeContext;
1766
1651
  if (normalizedGoal && this.config.isSubAgent !== true) {
1767
1652
  goalHookHandler = createGoalStopHook({
1768
1653
  goal: normalizedGoal,
1769
- llm: auxSummaryClient,
1654
+ llm: llmClient,
1770
1655
  log: logger,
1656
+ getJudgeContext: () => goalJudgeContext,
1771
1657
  // Clear the persisted active goal the moment the judge says it's met,
1772
1658
  // so a later bare send doesn't re-inherit a satisfied goal. The hook
1773
1659
  // calls this from inside its met branch (single source of truth for
1774
1660
  // "goal achieved"); engine owns the persistence side-effect.
1775
1661
  onMet: () => {
1776
- if (session.state.activeGoal) {
1662
+ if (persistedRunGoal &&
1663
+ isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1777
1664
  session.state.activeGoal = undefined;
1778
1665
  this.sessionManager.saveState(session.state);
1779
1666
  }
@@ -1783,7 +1670,7 @@ export class Engine {
1783
1670
  // in-RAM session are untouched) actually stops the judge. Reads disk
1784
1671
  // via readActiveGoal — authoritative and independent of which session
1785
1672
  // instance the run closure holds.
1786
- isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
1673
+ isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
1787
1674
  });
1788
1675
  this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
1789
1676
  // Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
@@ -1815,8 +1702,31 @@ export class Engine {
1815
1702
  return info;
1816
1703
  },
1817
1704
  consumeSteer: (source) => this.consumeSteer(sid, source),
1705
+ restoreSteer: (items) => this.restoreSteer(sid, items),
1706
+ buildSteerUserMessageContent: async (item) => {
1707
+ const steerImageInput = await prepareRunImageInput({
1708
+ task: item.text,
1709
+ cwd,
1710
+ llm: this.config.llm,
1711
+ sessionId: sid,
1712
+ attachments: item.attachments,
1713
+ });
1714
+ if (!steerImageInput.ok) {
1715
+ throw new Error(steerImageInput.result.text);
1716
+ }
1717
+ return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
1718
+ },
1818
1719
  claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
1720
+ releaseClientMessageId: (clientMessageId) => {
1721
+ claimedClientMessageIds.delete(clientMessageId);
1722
+ },
1723
+ setOriginClientMessageId: (clientMessageId) => {
1724
+ toolCtx.originClientMessageId = clientMessageId;
1725
+ },
1819
1726
  recordCumulativeUsage,
1727
+ recordCacheReadDiagnostics: (usage) => {
1728
+ this.recordCacheReadDiagnostics(sid, usage);
1729
+ },
1820
1730
  recordContextUsageAnchor: (anchor) => {
1821
1731
  session.state.contextUsageAnchor = {
1822
1732
  ...anchor,
@@ -1829,7 +1739,8 @@ export class Engine {
1829
1739
  // turns don't re-arm) AND persists it, and drops the in-flight stop
1830
1740
  // hook so nothing re-blocks the stop we're about to return.
1831
1741
  clearPersistedGoal: () => {
1832
- if (session.state.activeGoal !== undefined) {
1742
+ if (persistedRunGoal &&
1743
+ isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1833
1744
  session.state.activeGoal = undefined;
1834
1745
  this.sessionManager.saveState(session.state);
1835
1746
  }
@@ -1839,6 +1750,9 @@ export class Engine {
1839
1750
  this.activeGoalHook = null;
1840
1751
  }
1841
1752
  },
1753
+ updateGoalJudgeContext: (context) => {
1754
+ goalJudgeContext = context;
1755
+ },
1842
1756
  ctxOverheadStore: {
1843
1757
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
1844
1758
  set: (s, n) => {
@@ -1863,6 +1777,7 @@ export class Engine {
1863
1777
  onStream: options?.onStream,
1864
1778
  signal: options?.signal,
1865
1779
  freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
1780
+ volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
1866
1781
  // Goal mode: the active goal is surfaced to the on_stop handler via
1867
1782
  // ctx.data.goal; the GoalStopHook (registered above) judges it.
1868
1783
  goal: normalizedGoal,
@@ -1910,9 +1825,31 @@ export class Engine {
1910
1825
  // only — sub-agents don't carry user-clearable persistent goals.
1911
1826
  if (this.config.isSubAgent !== true)
1912
1827
  this.activeRunSession = session;
1828
+ const applyGoalTermination = (termination) => {
1829
+ if (!termination || !persistedRunGoal)
1830
+ return;
1831
+ // Record the terminal identity even when a newer goal has already
1832
+ // replaced it. Only clear activeGoal when it is still the run's goal.
1833
+ session.state.goalTerminal = {
1834
+ objective: persistedRunGoal.objective,
1835
+ setAtMs: persistedRunGoal.setAtMs,
1836
+ reason: termination,
1837
+ terminatedAtMs: Date.now(),
1838
+ };
1839
+ if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1840
+ session.state.activeGoal = undefined;
1841
+ }
1842
+ this.sessionManager.saveState(session.state);
1843
+ if (goalHookHandler) {
1844
+ this.hooks.unregister("on_stop", goalHookHandler);
1845
+ if (this.activeGoalHook === goalHookHandler)
1846
+ this.activeGoalHook = null;
1847
+ }
1848
+ };
1913
1849
  let result;
1914
1850
  try {
1915
1851
  result = await turnLoop.run(messages);
1852
+ applyGoalTermination(result.goalTermination);
1916
1853
  // ── Headless: drain background sub-agents before resolving ───────
1917
1854
  // Unified background-work model (2026-06-17): the engine NO LONGER parks
1918
1855
  // every run waiting on background work. Background work (sub-agents,
@@ -1974,6 +1911,7 @@ export class Engine {
1974
1911
  break;
1975
1912
  }
1976
1913
  result = await turnLoop.run([...result.messages, injected]);
1914
+ applyGoalTermination(result.goalTermination);
1977
1915
  }
1978
1916
  }
1979
1917
  }
@@ -1993,7 +1931,8 @@ export class Engine {
1993
1931
  this.hooks.unregister("on_tool_start", fileHistoryHandler);
1994
1932
  }
1995
1933
  this.lastMessages = result.messages;
1996
- this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
1934
+ const cachedMessages = this.stripInjectedContextMessages(result.messages, userContextMsg, dynamicContextMsg);
1935
+ this.compactedMessagesBySession.set(session.state.sessionId, cachedMessages);
1997
1936
  logger.info("engine.done", {
1998
1937
  sessionId: session.state.sessionId,
1999
1938
  reason: result.reason,
@@ -2648,11 +2587,39 @@ export class Engine {
2648
2587
  strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
2649
2588
  };
2650
2589
  }
2651
- stripUserContextMessage(messages, userContextMsg) {
2590
+ stripInjectedContextMessages(messages, userContextMsg, dynamicContextMsg) {
2591
+ const withoutDynamicContext = dynamicContextMsg
2592
+ ? messages.filter((msg) => msg !== dynamicContextMsg)
2593
+ : [...messages];
2652
2594
  if (!userContextMsg || messages[0] !== userContextMsg) {
2653
- return [...messages];
2595
+ return withoutDynamicContext;
2596
+ }
2597
+ return withoutDynamicContext.slice(1);
2598
+ }
2599
+ recordCacheReadDiagnostics(sessionId, usage) {
2600
+ const current = usage.cacheReadTokens;
2601
+ if (current === undefined || !Number.isFinite(current))
2602
+ return;
2603
+ const previous = this.lastCacheReadBySid.get(sessionId);
2604
+ this.lastCacheReadBySid.delete(sessionId);
2605
+ this.lastCacheReadBySid.set(sessionId, current);
2606
+ if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
2607
+ const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
2608
+ if (oldestSessionId !== undefined)
2609
+ this.lastCacheReadBySid.delete(oldestSessionId);
2610
+ }
2611
+ if (previous === undefined || previous < CACHE_READ_DROP_MIN_PREVIOUS_TOKENS)
2612
+ return;
2613
+ const dropRatio = previous > 0 ? current / previous : 1;
2614
+ if (current <= CACHE_READ_DROP_MAX_CURRENT_TOKENS && dropRatio <= CACHE_READ_DROP_RATIO) {
2615
+ logger.warn("engine.cache_read_drop", {
2616
+ sessionId,
2617
+ previousCacheReadTokens: previous,
2618
+ currentCacheReadTokens: current,
2619
+ dropRatio,
2620
+ hint: "Prompt cache read tokens dropped sharply. Check for changed cacheable prefix, stale dynamic context in history, tool/schema changes, or provider cache eviction.",
2621
+ });
2654
2622
  }
2655
- return messages.slice(1);
2656
2623
  }
2657
2624
  getSettingsManager() {
2658
2625
  if (!this.settingsManager) {
@@ -47,6 +47,21 @@ export interface GoalConfig {
47
47
  */
48
48
  setAtMs?: number;
49
49
  }
50
+ /** A forced terminal outcome for one concrete persisted goal instance. */
51
+ export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exhausted" | "time_budget_exhausted" | "max_turns_exhausted";
52
+ /**
53
+ * Persisted terminal marker used to prevent a stale whole-state writer from
54
+ * making an exhausted goal armable again. `objective + setAtMs` is the goal
55
+ * instance identity; `terminatedAtMs` only orders competing tombstones.
56
+ */
57
+ export interface GoalTerminal {
58
+ objective: string;
59
+ setAtMs?: number;
60
+ reason: GoalTerminationReason;
61
+ terminatedAtMs?: number;
62
+ }
63
+ /** True only when both values identify the same concrete goal instance. */
64
+ export declare function isSameGoalInstance(left: Pick<GoalConfig, "objective" | "setAtMs"> | undefined, right: Pick<GoalConfig, "objective" | "setAtMs"> | undefined): boolean;
50
65
  /**
51
66
  * Default consecutive-stop-block cap for goal runs. The real safety net for an
52
67
  * unattended goal is the token/time budget + maxTurns; this cap only stops a
@@ -185,3 +200,5 @@ export declare function recordGoalUsage(tracker: GoalBudgetTracker, turnTokens:
185
200
  * pass the current clock (and tests pass a fixed value).
186
201
  */
187
202
  export declare function goalBudgetExceeded(tracker: GoalBudgetTracker, nowMs: number): boolean;
203
+ /** Identify which goal budget forced termination, preserving token-first precedence. */
204
+ export declare function goalBudgetTerminationReason(tracker: GoalBudgetTracker, nowMs: number): Extract<GoalTerminationReason, "token_budget_exhausted" | "time_budget_exhausted"> | undefined;
@@ -10,6 +10,10 @@
10
10
  * it accumulates total tokens across turns and stamps a wall-clock start,
11
11
  * so an unattended goal run can't burn tokens or wall time without bound.
12
12
  */
13
+ /** True only when both values identify the same concrete goal instance. */
14
+ export function isSameGoalInstance(left, right) {
15
+ return !!left && !!right && left.objective === right.objective && left.setAtMs === right.setAtMs;
16
+ }
13
17
  /**
14
18
  * Default consecutive-stop-block cap for goal runs. The real safety net for an
15
19
  * unattended goal is the token/time budget + maxTurns; this cap only stops a
@@ -94,7 +98,7 @@ export function normalizeGoal(raw) {
94
98
  */
95
99
  export function resolveGoalSetAt(explicitObjective, stored, nowMs) {
96
100
  const sameGoalContinues = !!stored && stored.objective === explicitObjective;
97
- return sameGoalContinues ? stored.setAtMs ?? nowMs : nowMs;
101
+ return sameGoalContinues ? (stored.setAtMs ?? nowMs) : nowMs;
98
102
  }
99
103
  /** Interactive (no-goal) turn ceiling — a single prompt rarely needs more. */
100
104
  export const INTERACTIVE_DEFAULT_MAX_TURNS = 100;
@@ -187,10 +191,16 @@ export function recordGoalUsage(tracker, turnTokens) {
187
191
  * pass the current clock (and tests pass a fixed value).
188
192
  */
189
193
  export function goalBudgetExceeded(tracker, nowMs) {
194
+ return goalBudgetTerminationReason(tracker, nowMs) !== undefined;
195
+ }
196
+ /** Identify which goal budget forced termination, preserving token-first precedence. */
197
+ export function goalBudgetTerminationReason(tracker, nowMs) {
190
198
  const { tokenBudget, timeBudgetMs } = tracker.goal;
191
- if (typeof tokenBudget === "number" && tracker.tokensUsed > tokenBudget)
192
- return true;
193
- if (typeof timeBudgetMs === "number" && nowMs - tracker.startedAtMs > timeBudgetMs)
194
- return true;
195
- return false;
199
+ if (typeof tokenBudget === "number" && tracker.tokensUsed > tokenBudget) {
200
+ return "token_budget_exhausted";
201
+ }
202
+ if (typeof timeBudgetMs === "number" && nowMs - tracker.startedAtMs > timeBudgetMs) {
203
+ return "time_budget_exhausted";
204
+ }
205
+ return undefined;
196
206
  }