@cjhyy/code-shell-core 0.9.2 → 0.9.4

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 (38) hide show
  1. package/dist/cli/agent-server-stdio.js +26 -1
  2. package/dist/cli/agent-server-tcp.js +58 -7
  3. package/dist/engine/engine.d.ts +8 -0
  4. package/dist/engine/engine.js +16 -12
  5. package/dist/engine/model-facade.d.ts +3 -0
  6. package/dist/engine/model-facade.js +2 -0
  7. package/dist/engine/run-tooling.js +8 -8
  8. package/dist/engine/streaming-tool-queue.d.ts +4 -1
  9. package/dist/engine/streaming-tool-queue.js +17 -2
  10. package/dist/engine/turn-loop.d.ts +14 -5
  11. package/dist/engine/turn-loop.js +92 -37
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/llm/prompt-cache.d.ts +48 -0
  15. package/dist/llm/prompt-cache.js +100 -0
  16. package/dist/llm/providers/anthropic.d.ts +3 -0
  17. package/dist/llm/providers/anthropic.js +77 -53
  18. package/dist/llm/providers/openai.d.ts +7 -27
  19. package/dist/llm/providers/openai.js +120 -68
  20. package/dist/llm/types.d.ts +3 -0
  21. package/dist/onboarding.js +72 -49
  22. package/dist/panel-apps/manifest.d.ts +12 -12
  23. package/dist/profile/types.d.ts +26 -26
  24. package/dist/protocol/background-result-wakeup.d.ts +3 -1
  25. package/dist/protocol/background-result-wakeup.js +5 -5
  26. package/dist/protocol/server.d.ts +13 -0
  27. package/dist/protocol/server.js +22 -3
  28. package/dist/services/index.d.ts +0 -1
  29. package/dist/services/index.js +0 -1
  30. package/dist/session/memory.js +2 -2
  31. package/dist/session/session-manager.js +30 -1
  32. package/dist/tool-system/builtin/agent-notifications.d.ts +25 -1
  33. package/dist/tool-system/builtin/agent-notifications.js +334 -2
  34. package/dist/tool-system/context.d.ts +9 -4
  35. package/dist/tool-system/external-tool-exposure.js +11 -10
  36. package/package.json +2 -1
  37. package/dist/services/notifier.d.ts +0 -33
  38. package/dist/services/notifier.js +0 -83
@@ -30,10 +30,11 @@
30
30
  * bootstrap is invisible to them.
31
31
  */
32
32
  import { join } from "node:path";
33
+ import { existsSync, readdirSync } from "node:fs";
33
34
  import { Engine } from "../engine/engine.js";
34
35
  import { EngineRuntime } from "../engine/runtime.js";
35
36
  import { ChatSessionManager } from "../protocol/chat-session-manager.js";
36
- import { SessionManager } from "../session/session-manager.js";
37
+ import { assertSafeSessionId, SessionManager, sessionsRoot } from "../session/session-manager.js";
37
38
  import { validateSettings } from "../settings/schema.js";
38
39
  import { AgentServer } from "../protocol/server.js";
39
40
  import { StdioTransport } from "../protocol/transport.js";
@@ -122,6 +123,29 @@ const composition = compileComposition({ modules: await loadConfiguredAgentModul
122
123
  // byte-for-byte.
123
124
  const dataRoot = process.env.CODE_SHELL_DATA_ROOT?.trim() || undefined;
124
125
  const dataSessionsDir = dataRoot ? join(dataRoot, "sessions") : undefined;
126
+ const notificationSessionsDir = dataSessionsDir ?? sessionsRoot();
127
+ const notificationPersistence = {
128
+ fileForSession(sessionId) {
129
+ try {
130
+ assertSafeSessionId(sessionId);
131
+ return join(notificationSessionsDir, sessionId, "pending-notifications.json");
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ },
137
+ listSessionIds() {
138
+ try {
139
+ return readdirSync(notificationSessionsDir, { withFileTypes: true })
140
+ .filter((entry) => entry.isDirectory() &&
141
+ existsSync(join(notificationSessionsDir, entry.name, "pending-notifications.json")))
142
+ .map((entry) => entry.name);
143
+ }
144
+ catch {
145
+ return [];
146
+ }
147
+ },
148
+ };
125
149
  // Load settings once to derive llm config for the seed engine.
126
150
  // Desktop is a host application: read the full disk hierarchy (incl. the
127
151
  // user's ~/.code-shell). The SDK default 'project' would skip user config.
@@ -364,6 +388,7 @@ const agentServer = new AgentServer({
364
388
  // Cold background-wakeup rehydrate must read the same sessions store the
365
389
  // engines write when the data root is relocated (undefined → default root).
366
390
  sessionDiskRoot: dataSessionsDir,
391
+ notificationPersistence,
367
392
  // Config hot-reload (layer 2) reads disk through the SAME closure the
368
393
  // engineFactory uses for new sessions, so a reloaded running session and a
369
394
  // newly-created session converge on identical disk config (no divergence).
@@ -34,11 +34,39 @@ import { startAutomation } from "../automation/index.js";
34
34
  import { CronStore, defaultCronStorePath } from "../automation/store.js";
35
35
  import { resolveLLMConfigForTag } from "../engine/resolve-llm-config.js";
36
36
  import { randomUUID } from "node:crypto";
37
+ import { existsSync, readdirSync } from "node:fs";
38
+ import { join } from "node:path";
37
39
  import { getApprovalRouter } from "../tool-system/permission.js";
38
- import { SessionManager } from "../session/session-manager.js";
40
+ import { assertSafeSessionId, SessionManager, sessionsRoot } from "../session/session-manager.js";
41
+ import { notificationQueue } from "../tool-system/builtin/agent-notifications.js";
39
42
  const cwd = process.env.AGENT_CWD ?? process.cwd();
40
43
  const port = Number(process.env.AGENT_TCP_PORT ?? "4321");
41
44
  const host = process.env.AGENT_TCP_HOST ?? "127.0.0.1";
45
+ const dataRoot = process.env.CODE_SHELL_DATA_ROOT?.trim() || undefined;
46
+ const dataSessionsDir = dataRoot ? join(dataRoot, "sessions") : undefined;
47
+ const notificationSessionsDir = dataSessionsDir ?? sessionsRoot();
48
+ notificationQueue.attachPersistence({
49
+ fileForSession(sessionId) {
50
+ try {
51
+ assertSafeSessionId(sessionId);
52
+ return join(notificationSessionsDir, sessionId, "pending-notifications.json");
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ },
58
+ listSessionIds() {
59
+ try {
60
+ return readdirSync(notificationSessionsDir, { withFileTypes: true })
61
+ .filter((entry) => entry.isDirectory() &&
62
+ existsSync(join(notificationSessionsDir, entry.name, "pending-notifications.json")))
63
+ .map((entry) => entry.name);
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ },
69
+ });
42
70
  const settingsManager = new SettingsManager(cwd, "full");
43
71
  // Read once at startup and reuse for every session (see engineFactory below).
44
72
  // This is intentional for the TCP host: it's a headless long-running server,
@@ -53,7 +81,12 @@ if (!seedLlm) {
53
81
  }
54
82
  const llmConfig = seedLlm;
55
83
  // ── Shared runtime (same bootstrap as stdio) ─────────────────────
56
- const seedEngine = new Engine({ llm: llmConfig, cwd, settingsScope: "full" });
84
+ const seedEngine = new Engine({
85
+ llm: llmConfig,
86
+ cwd,
87
+ settingsScope: "full",
88
+ sessionStorageDir: dataSessionsDir,
89
+ });
57
90
  const modelPool = seedEngine.getModelPool();
58
91
  const toolRegistry = seedEngine.getRuntimeToolRegistry();
59
92
  const resolvedLlmConfig = seedEngine.getConfig().llm;
@@ -92,11 +125,13 @@ const chatManager = new ChatSessionManager({
92
125
  ...personalizationFrom(settings.agent),
93
126
  maxTurns: slice.maxTurns,
94
127
  maxContextTokens: slice.maxContextTokens,
128
+ sessionStorageDir: slice.sessionStorageDir ?? dataSessionsDir,
95
129
  ...(slice.cwd ? { cwd: slice.cwd } : {}),
96
130
  });
97
131
  },
98
132
  maxSessions: 16,
99
133
  idleTtlMs: 30 * 60 * 1000,
134
+ ...(dataRoot ? { dataRoot } : {}),
100
135
  });
101
136
  chatManager.startIdleSweeper();
102
137
  // ── Automation (same module the desktop loads) ──────────────────
@@ -109,23 +144,38 @@ const automationRunManager = createRunManager({
109
144
  approvalBackend: new HeadlessApprovalBackend("approve-read-only"),
110
145
  });
111
146
  const automation = startAutomation({
112
- store: new CronStore(defaultCronStorePath()),
147
+ store: new CronStore(defaultCronStorePath(dataRoot)),
113
148
  runManager: automationRunManager,
114
149
  });
115
150
  // ── Serve over TCP ──────────────────────────────────────────────
116
151
  // One AgentServer per accepted connection, all sharing the same chatManager.
117
152
  const servers = new Set();
118
- const goalDiskManager = new SessionManager();
119
- listenTcp({ port, host }, (transport, socket) => {
120
- const server = new AgentServer({
153
+ const goalDiskManager = new SessionManager(dataSessionsDir);
154
+ function createTcpAgentServer(transport, connectionId, ownsBackgroundWakeups) {
155
+ return new AgentServer({
121
156
  chatManager,
122
157
  transport,
123
- connectionId: randomUUID(),
158
+ connectionId,
124
159
  approvalRouter: getApprovalRouter(),
160
+ sessionDiskRoot: dataSessionsDir,
161
+ ownsBackgroundWakeups,
125
162
  readActiveGoalFromDisk: (sessionId) => goalDiskManager.readActiveGoal(sessionId),
126
163
  updateActiveGoalOnDisk: (sessionId, patch) => goalDiskManager.updateActiveGoal(sessionId, patch)?.goal,
127
164
  clearActiveGoalOnDisk: (sessionId, expected) => goalDiskManager.clearActiveGoal(sessionId, expected),
128
165
  });
166
+ }
167
+ // Own restoration for the whole TCP process, not for the first client. This
168
+ // wakes pending chats immediately after a headless restart even if nobody has
169
+ // connected a UI yet. Per-connection servers still forward live observations
170
+ // but do not race this owner for mailbox consumption.
171
+ const backgroundWakeTransport = {
172
+ send() { },
173
+ onMessage() { },
174
+ close() { },
175
+ };
176
+ const backgroundWakeServer = createTcpAgentServer(backgroundWakeTransport, "tcp-background-wakeup", true);
177
+ listenTcp({ port, host }, (transport, socket) => {
178
+ const server = createTcpAgentServer(transport, randomUUID(), false);
129
179
  servers.add(server);
130
180
  socket.once("close", () => {
131
181
  server.disconnect();
@@ -138,6 +188,7 @@ listenTcp({ port, host }, (transport, socket) => {
138
188
  automation.stop();
139
189
  for (const s of servers)
140
190
  s.close();
191
+ backgroundWakeServer.close();
141
192
  void listener.close().then(() => process.exit(0));
142
193
  };
143
194
  process.on("SIGTERM", shutdown);
@@ -317,6 +317,14 @@ export declare class Engine {
317
317
  * in-process AgentServer to decide whether to wire an interactive askUser.
318
318
  */
319
319
  isHeadless(): boolean;
320
+ /**
321
+ * A background_notification park only makes sense where the Session can be
322
+ * woken by the completion later (server refuses headless; sub-agent sessions
323
+ * are not in chatManager) — everywhere else honouring it would end the run
324
+ * early and orphan the background result. A reply_committed boundary is
325
+ * synchronous and never suppressed.
326
+ */
327
+ private suppressesRunYield;
320
328
  get permissionMode(): NonNullable<EngineConfig["permissionMode"]>;
321
329
  get planMode(): boolean;
322
330
  /**
@@ -804,6 +804,16 @@ export class Engine {
804
804
  isHeadless() {
805
805
  return this.config.headless === true;
806
806
  }
807
+ /**
808
+ * A background_notification park only makes sense where the Session can be
809
+ * woken by the completion later (server refuses headless; sub-agent sessions
810
+ * are not in chatManager) — everywhere else honouring it would end the run
811
+ * early and orphan the background result. A reply_committed boundary is
812
+ * synchronous and never suppressed.
813
+ */
814
+ suppressesRunYield(reason) {
815
+ return (reason === "background_notification" && (this.isHeadless() || this.config.isSubAgent === true));
816
+ }
807
817
  get permissionMode() {
808
818
  return this.permissionController.permissionMode;
809
819
  }
@@ -2172,18 +2182,12 @@ export class Engine {
2172
2182
  publishGoalJudgeContext: (context) => {
2173
2183
  publishGoalJudgeContext(context);
2174
2184
  },
2175
- // A background_notification yield parks the run until the Session is
2176
- // woken by the completion notification. Only a top-level interactive
2177
- // session can be woken (server refuses headless; sub-agent sessions
2178
- // are not in chatManager) everywhere else honouring the yield would
2179
- // end the run early and orphan the background result, so the loop
2180
- // never sees the request and the model keeps its full turn.
2181
- ...(this.isHeadless() || this.config.isSubAgent === true
2182
- ? {}
2183
- : {
2184
- peekToolRunYield: () => toolCtx.runYield?.peek?.(),
2185
- consumeToolRunYield: () => toolCtx.runYield?.consume(),
2186
- }),
2185
+ // A background_notification yield is visible only where the Session
2186
+ // can be woken later. A committed host reply is a synchronous terminal
2187
+ // boundary, so it must remain visible in headless and sub-agent runs.
2188
+ // One predicate serves peek and consume so they can never disagree.
2189
+ peekToolRunYield: (reason) => !this.suppressesRunYield(reason) && toolCtx.runYield?.peek(reason) === true,
2190
+ consumeToolRunYield: (reason) => !this.suppressesRunYield(reason) && toolCtx.runYield?.consume(reason) === true,
2187
2191
  ctxOverheadStore: {
2188
2192
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
2189
2193
  set: (s, n) => {
@@ -2,11 +2,14 @@
2
2
  * Model call facade — wraps LLM client with transcript integration.
3
3
  */
4
4
  import type { LLMClientBase } from "../llm/client-base.js";
5
+ import type { PromptCacheRequestContext } from "../llm/prompt-cache.js";
5
6
  import type { Message, ToolDefinition, LLMResponse, StreamCallback } from "../types.js";
6
7
  import { Transcript } from "../session/transcript.js";
7
8
  import { type PromptPrefixFingerprint } from "./prompt-cache-diagnostics.js";
8
9
  export interface ModelCallRecordingOptions {
9
10
  sensitiveToolResultRedactions?: ReadonlyMap<string, string>;
11
+ /** Cache boundary metadata; scopeId is filled from the active session. */
12
+ promptCache?: Omit<PromptCacheRequestContext, "scopeId">;
10
13
  }
11
14
  /**
12
15
  * Prompt-cache hit rate for one request, as CC computes it:
@@ -89,6 +89,7 @@ export class ModelFacade {
89
89
  }
90
90
  },
91
91
  signal,
92
+ promptCache: { scopeId: sid, ...recordingOptions?.promptCache },
92
93
  });
93
94
  }
94
95
  catch (err) {
@@ -144,6 +145,7 @@ export class ModelFacade {
144
145
  tools,
145
146
  stream: false,
146
147
  signal,
148
+ promptCache: { scopeId: sid, ...recordingOptions?.promptCache },
147
149
  });
148
150
  }
149
151
  catch (err) {
@@ -10,7 +10,7 @@ import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
10
10
  /** engine.ts L1485-1522 —— ToolContext 组装(spawner、agentDefinitions、base 由调用方传入)。 */
11
11
  export function buildRunToolContext(args) {
12
12
  const { options, profile, profileParams } = args;
13
- let pendingRunYield;
13
+ const pendingRunYield = new Set();
14
14
  // sessionId is filled in after the session bundle is resolved below
15
15
  // (the session may be cold-started or resumed). Until then this is
16
16
  // intentionally shaped as a mutable local; we treat it as immutable
@@ -40,16 +40,16 @@ export function buildRunToolContext(args) {
40
40
  toolCtx.cwd = nextCwd;
41
41
  },
42
42
  runYield: {
43
+ // Reasons accumulate independently — a batch may both commit a host
44
+ // reply and launch background work; the turn loop decides precedence.
43
45
  request(reason) {
44
- pendingRunYield ??= reason;
46
+ pendingRunYield.add(reason);
45
47
  },
46
- peek() {
47
- return pendingRunYield;
48
+ peek(reason) {
49
+ return pendingRunYield.has(reason);
48
50
  },
49
- consume() {
50
- const reason = pendingRunYield;
51
- pendingRunYield = undefined;
52
- return reason;
51
+ consume(reason) {
52
+ return pendingRunYield.delete(reason);
53
53
  },
54
54
  },
55
55
  skillAllowlist: options?.skillAllowlist !== undefined
@@ -17,12 +17,15 @@ import type { ToolCall, ToolResult } from "../types.js";
17
17
  import type { ToolExecutor } from "../tool-system/executor.js";
18
18
  export declare class StreamingToolQueue {
19
19
  private readonly executor;
20
+ private readonly pendingUnsafeSkipReason?;
20
21
  private readonly pending;
21
22
  private readonly unsafeQueue;
22
23
  private readonly callOrder;
23
24
  private readonly toolNameById;
24
25
  private draining;
25
- constructor(executor: ToolExecutor);
26
+ constructor(executor: ToolExecutor, options?: {
27
+ pendingUnsafeSkipReason?: () => string | undefined;
28
+ });
26
29
  /**
27
30
  * Enqueue a tool from the completed response. Concurrency-safe tools start
28
31
  * immediately within this post-response phase; unsafe tools are queued for
@@ -15,13 +15,15 @@
15
15
  */
16
16
  export class StreamingToolQueue {
17
17
  executor;
18
+ pendingUnsafeSkipReason;
18
19
  pending = new Map();
19
20
  unsafeQueue = [];
20
21
  callOrder = [];
21
22
  toolNameById = new Map();
22
23
  draining = false;
23
- constructor(executor) {
24
+ constructor(executor, options) {
24
25
  this.executor = executor;
26
+ this.pendingUnsafeSkipReason = options?.pendingUnsafeSkipReason;
25
27
  }
26
28
  /**
27
29
  * Enqueue a tool from the completed response. Concurrency-safe tools start
@@ -62,8 +64,21 @@ export class StreamingToolQueue {
62
64
  this.draining = true;
63
65
  const resultMap = new Map();
64
66
  // Execute unsafe tools sequentially. A rejection here must not stop the
65
- // remaining unsafe tools from running.
67
+ // remaining unsafe tools from running. A trusted terminal boundary may,
68
+ // however, skip calls that have not started yet. We still synthesize one
69
+ // result per call so tool_use/tool_result history remains structurally
70
+ // complete even though the run will stop after this batch.
66
71
  for (const call of this.unsafeQueue) {
72
+ const skipReason = this.pendingUnsafeSkipReason?.();
73
+ if (skipReason) {
74
+ resultMap.set(call.id, {
75
+ id: call.id,
76
+ toolName: call.toolName,
77
+ error: `Tool execution skipped: ${skipReason}`,
78
+ isError: true,
79
+ });
80
+ continue;
81
+ }
67
82
  const p = this.executor.executeSingle(call);
68
83
  this.pending.set(call.id, p);
69
84
  resultMap.set(call.id, await this.toResult(call.id, call.toolName, p));
@@ -150,10 +150,10 @@ export interface TurnLoopDeps {
150
150
  * built-in judge closure; it is never added to the public on_stop context.
151
151
  */
152
152
  publishGoalJudgeContext?: (context: GoalJudgeRuntimeContext) => void;
153
- /** Inspect a trusted tool's pending run yield without clearing it. */
154
- peekToolRunYield?: () => import("../tool-system/context.js").ToolRunYieldReason | undefined;
155
- /** Consume a trusted tool's request to yield until an async notification. */
156
- consumeToolRunYield?: () => import("../tool-system/context.js").ToolRunYieldReason | undefined;
153
+ /** Whether a trusted tool has a specific run boundary pending (not cleared). */
154
+ peekToolRunYield?: (reason: import("../tool-system/context.js").ToolRunYieldReason) => boolean;
155
+ /** Consume a specific pending run boundary; true if it was pending. */
156
+ consumeToolRunYield?: (reason: import("../tool-system/context.js").ToolRunYieldReason) => boolean;
157
157
  }
158
158
  export interface TurnLoopResult {
159
159
  text: string;
@@ -287,7 +287,16 @@ export declare class TurnLoop {
287
287
  private finalizeModelTurn;
288
288
  private prepareMessagesForModel;
289
289
  private stripVolatileContextMessages;
290
- private appendVolatileContextMessages;
290
+ /**
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.
296
+ */
297
+ private restoreVolatileAfterContextManagement;
298
+ private manageContextMessages;
299
+ private manageContextMessagesSync;
291
300
  private markPendingImagesConsumed;
292
301
  private redactConsumedSensitiveToolResults;
293
302
  private modelCallRecordingOptions;
@@ -340,10 +340,36 @@ export class TurnLoop {
340
340
  });
341
341
  return changed ? stripped : messages;
342
342
  }
343
- appendVolatileContextMessages(messages) {
344
- if (this.volatileContextMessages.size === 0)
345
- return messages;
346
- return [...this.stripVolatileContextMessages(messages), ...this.volatileContextMessages];
343
+ /**
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.
349
+ */
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;
355
+ const volatile = original.filter((message) => this.volatileContextMessages.has(message));
356
+ return [...managedStable, ...volatile];
357
+ }
358
+ async manageContextMessages(messages) {
359
+ if (this.volatileContextMessages.size === 0) {
360
+ return this.deps.contextManager.manageAsync(messages, this.config.signal);
361
+ }
362
+ const stable = this.stripVolatileContextMessages(messages);
363
+ const managed = await this.deps.contextManager.manageAsync(stable, this.config.signal);
364
+ return this.restoreVolatileAfterContextManagement(messages, stable, managed);
365
+ }
366
+ manageContextMessagesSync(messages) {
367
+ if (this.volatileContextMessages.size === 0) {
368
+ return this.deps.contextManager.manage(messages);
369
+ }
370
+ const stable = this.stripVolatileContextMessages(messages);
371
+ const managed = this.deps.contextManager.manage(stable);
372
+ return this.restoreVolatileAfterContextManagement(messages, stable, managed);
347
373
  }
348
374
  markPendingImagesConsumed(messages) {
349
375
  if (this.pendingImageMessages.size === 0)
@@ -367,11 +393,15 @@ export class TurnLoop {
367
393
  this.sensitiveToolResultRedactions.clear();
368
394
  return redacted;
369
395
  }
370
- modelCallRecordingOptions() {
371
- if (this.sensitiveToolResultRedactions.size === 0)
372
- return undefined;
396
+ modelCallRecordingOptions(messages) {
397
+ const volatileIndex = messages.findIndex((message) => this.volatileContextMessages.has(message));
373
398
  return {
374
- sensitiveToolResultRedactions: new Map(this.sensitiveToolResultRedactions),
399
+ ...(this.sensitiveToolResultRedactions.size > 0
400
+ ? { sensitiveToolResultRedactions: new Map(this.sensitiveToolResultRedactions) }
401
+ : {}),
402
+ promptCache: {
403
+ stablePrefixMessageCount: volatileIndex >= 0 ? volatileIndex : messages.length,
404
+ },
375
405
  };
376
406
  }
377
407
  trackFreshImageMessage(message) {
@@ -657,7 +687,6 @@ export class TurnLoop {
657
687
  // pendingImageMessages are preserved through this next model request.
658
688
  const hasPendingSensitiveToolResults = this.sensitiveToolResultRedactions.size > 0;
659
689
  messages = this.prepareMessagesForModel(messages);
660
- messages = this.stripVolatileContextMessages(messages);
661
690
  if (hasPendingSensitiveToolResults) {
662
691
  tlog.info("turn.sensitive_tool_result_context_management_skipped", {
663
692
  cat: "turn",
@@ -666,7 +695,7 @@ export class TurnLoop {
666
695
  }
667
696
  else {
668
697
  // Context management (async — may trigger LLM summarization)
669
- messages = await this.deps.contextManager.manageAsync(messages, this.config.signal);
698
+ messages = await this.manageContextMessages(messages);
670
699
  // manageAsync can itself issue an LLM summarization call lasting several
671
700
  // seconds; if the signal aborted during it, stop here rather than
672
701
  // proceeding into the (expensive) main model call. Belt to the loop-top
@@ -710,7 +739,6 @@ export class TurnLoop {
710
739
  messages = this.redactConsumedSensitiveToolResults(messages);
711
740
  return { text: finalText, reason: "completed", messages };
712
741
  }
713
- messages = this.appendVolatileContextMessages(messages);
714
742
  // Model call (with streaming fallback and max_output_tokens continuation)
715
743
  this.config.onStream?.({
716
744
  type: "stream_request_start",
@@ -721,7 +749,15 @@ export class TurnLoop {
721
749
  this.streamedToolIds.clear();
722
750
  // Tool queue is created before the call, but enqueue happens only after
723
751
  // the complete LLMResponse is available below.
724
- const streamingQueue = new StreamingToolQueue(this.deps.toolExecutor);
752
+ const streamingQueue = new StreamingToolQueue(this.deps.toolExecutor, {
753
+ // Once a trusted reply tool commits the authoritative host response,
754
+ // later sequential calls from the same model batch must not execute.
755
+ // Concurrency-safe calls may already be running; drain still awaits
756
+ // those so the transcript remains complete.
757
+ pendingUnsafeSkipReason: () => this.deps.peekToolRunYield?.("reply_committed")
758
+ ? "an authoritative host reply was already committed"
759
+ : undefined,
760
+ });
725
761
  let response;
726
762
  try {
727
763
  response = await this.callModelWithFallback(messages, assistantMessageId);
@@ -891,7 +927,8 @@ export class TurnLoop {
891
927
  },
892
928
  ];
893
929
  try {
894
- const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
930
+ const preparedContinuationMessages = this.prepareMessagesForModel(contMessages);
931
+ const contResponse = await this.deps.model.call(this.deps.systemPrompt, preparedContinuationMessages, this.deps.tools, this.config.onStream, this.config.signal, this.modelCallRecordingOptions(preparedContinuationMessages));
895
932
  // Continuations are separate provider responses, so preserve the
896
933
  // same structural tool_use invariant before processing this one.
897
934
  if (contResponse.toolCalls.length > 0) {
@@ -995,7 +1032,7 @@ export class TurnLoop {
995
1032
  // answered before parking the run. Keep the tool's yield request
996
1033
  // pending across that extra model round, then park once the model
997
1034
  // has replied and there is still no background result to consume.
998
- if (this.deps.consumeToolRunYield?.() === "background_notification") {
1035
+ if (this.deps.consumeToolRunYield?.("background_notification")) {
999
1036
  tlog.info("turn.background_notification_wait_after_steer", { cat: "turn" });
1000
1037
  messages = this.redactConsumedSensitiveToolResults(messages);
1001
1038
  return {
@@ -1272,30 +1309,47 @@ export class TurnLoop {
1272
1309
  // Tool results just pushed; recompute ctx so the bar updates *before*
1273
1310
  // the next model round-trip — large tool outputs can move it sharply.
1274
1311
  this.emitCtxFromMessages(messages);
1275
- // A trusted tool launched asynchronous work whose completion is routed
1276
- // back into this Session. End this run at the tool boundary instead of
1277
- // asking the model for another step with no new evidence; the queued
1278
- // completion notification will wake the Session and continue normally.
1279
- // This precedes complete_goal so a single batch cannot launch unfinished
1280
- // background work and simultaneously claim the enclosing Goal is done.
1281
- if (this.deps.peekToolRunYield?.() === "background_notification") {
1312
+ // A trusted tool marked a run boundary. reply_committed: the
1313
+ // authoritative user-facing reply is recorded another model request
1314
+ // could only produce duplicate tools or stray assistant text.
1315
+ // background_notification: async work was launched whose completion
1316
+ // notification will wake this Session, so the run parks instead of
1317
+ // asking the model for another step with no new evidence. One batch
1318
+ // may pend both; the park wins so the completion finds a parked run.
1319
+ const replyCommitted = this.deps.peekToolRunYield?.("reply_committed") === true;
1320
+ const backgroundWait = this.deps.peekToolRunYield?.("background_notification") === true;
1321
+ if (replyCommitted || backgroundWait) {
1282
1322
  // Close the current model turn before a queued steer re-drives it;
1283
1323
  // interrupt-and-redrive swaps the turn signal at this boundary.
1284
1324
  this.finalizeModelTurn();
1285
1325
  if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
1326
+ // A user message arrived while the batch was executing. The
1327
+ // committed reply answered the previous content only — lift the
1328
+ // reply barrier so the re-driven round can answer the new message
1329
+ // (the host itself rejects duplicate authoritative replies), and
1330
+ // keep any background park pending across the extra round.
1331
+ if (replyCommitted)
1332
+ this.deps.consumeToolRunYield?.("reply_committed");
1286
1333
  continue;
1287
1334
  }
1288
- }
1289
- if (this.deps.peekToolRunYield?.() === "background_notification" &&
1290
- this.deps.consumeToolRunYield?.() === "background_notification") {
1291
- tlog.info("turn.background_notification_wait", { cat: "turn" });
1292
- messages = this.redactConsumedSensitiveToolResults(messages);
1293
- return {
1294
- text: finalText,
1295
- reason: "completed",
1296
- messages,
1297
- completionKind: "background_wait",
1298
- };
1335
+ // Park before complete_goal so a single batch cannot launch
1336
+ // unfinished background work and simultaneously claim the enclosing
1337
+ // Goal is done.
1338
+ if (backgroundWait && this.deps.consumeToolRunYield?.("background_notification")) {
1339
+ tlog.info("turn.background_notification_wait", { cat: "turn" });
1340
+ messages = this.redactConsumedSensitiveToolResults(messages);
1341
+ return {
1342
+ text: finalText,
1343
+ reason: "completed",
1344
+ messages,
1345
+ completionKind: "background_wait",
1346
+ };
1347
+ }
1348
+ if (replyCommitted && this.deps.consumeToolRunYield?.("reply_committed")) {
1349
+ tlog.info("turn.reply_committed_stop", { cat: "turn" });
1350
+ messages = this.redactConsumedSensitiveToolResults(messages);
1351
+ return { text: finalText, reason: "completed", messages };
1352
+ }
1299
1353
  }
1300
1354
  // Goal mode P0: explicit completion. If the model called complete_goal,
1301
1355
  // it has DECLARED the goal done — short-circuit to "completed" WITHOUT
@@ -1496,7 +1550,7 @@ export class TurnLoop {
1496
1550
  });
1497
1551
  }
1498
1552
  else {
1499
- messages = this.deps.contextManager.manage(messages);
1553
+ messages = this.manageContextMessagesSync(messages);
1500
1554
  }
1501
1555
  if (this.goalControlStopRequested) {
1502
1556
  messages = this.redactConsumedSensitiveToolResults(messages);
@@ -1512,8 +1566,9 @@ export class TurnLoop {
1512
1566
  messages = this.redactConsumedSensitiveToolResults(messages);
1513
1567
  return { text: finalText, reason: "completed", messages };
1514
1568
  }
1515
- const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
1516
- this.config.onStream, this.config.signal, this.modelCallRecordingOptions());
1569
+ const summaryMessages = this.prepareMessagesForModel(messages);
1570
+ const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, summaryMessages, [], // No tools available for summary turn
1571
+ this.config.onStream, this.config.signal, this.modelCallRecordingOptions(summaryMessages));
1517
1572
  if (summaryResponse.usage?.promptTokens !== undefined) {
1518
1573
  this.recordResponseUsage(summaryResponse.usage, "primary", false);
1519
1574
  }
@@ -1591,7 +1646,7 @@ export class TurnLoop {
1591
1646
  }
1592
1647
  : undefined;
1593
1648
  try {
1594
- return await this.deps.model.call(this.deps.systemPrompt, messages, this.deps.tools, wrappedStream, this.config.signal, this.modelCallRecordingOptions());
1649
+ return await this.deps.model.call(this.deps.systemPrompt, messages, this.deps.tools, wrappedStream, this.config.signal, this.modelCallRecordingOptions(messages));
1595
1650
  }
1596
1651
  catch (err) {
1597
1652
  // If it's a context or rate limit error, don't fallback — propagate
@@ -1637,7 +1692,7 @@ export class TurnLoop {
1637
1692
  error: err.message,
1638
1693
  });
1639
1694
  // Retry without streaming
1640
- return await this.deps.model.callWithoutStreaming(this.deps.systemPrompt, messages, this.deps.tools, this.config.signal, this.modelCallRecordingOptions());
1695
+ return await this.deps.model.callWithoutStreaming(this.deps.systemPrompt, messages, this.deps.tools, this.config.signal, this.modelCallRecordingOptions(messages));
1641
1696
  }
1642
1697
  }
1643
1698
  get currentTurn() {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.9.2";
6
+ export declare const VERSION = "0.9.4";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionProjectBinding, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
9
9
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, CompositionError, SandboxUnavailableError, } from "./exceptions.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.9.2";
6
+ export const VERSION = "0.9.4";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, CompositionError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Composition (AgentModule / ResolvedComposition) ─────────────