mixdog 1.3.4 → 1.3.5

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 (197) hide show
  1. package/LICENSES/oh-my-pi-MIT.txt +23 -0
  2. package/NOTICE.md +15 -0
  3. package/README.md +11 -0
  4. package/package.json +2 -1
  5. package/scripts/embed-google-oauth-client.mjs +49 -0
  6. package/scripts/lib/test-spawn-ledger.mjs +46 -1
  7. package/scripts/test.mjs +5 -0
  8. package/src/defaults/shipped-history.json +8 -0
  9. package/src/defaults/skills/browser-use/SKILL.md +21 -9
  10. package/src/defaults/skills/setup/SKILL.md +7 -2
  11. package/src/defaults/skills/setup/references/actions.md +82 -27
  12. package/src/defaults/skills/setup/references/surfaces.md +67 -34
  13. package/src/defaults/skills/setup-guide/SKILL.md +3 -3
  14. package/src/lib/keychain-cjs.cjs +359 -29
  15. package/src/rules/agent-mode/AGENT_MODE.md +10 -2
  16. package/src/rules/agent-mode/MIXY.md +12 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +193 -0
  18. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  19. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +2 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  21. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +31 -2
  22. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +16 -3
  23. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +42 -7
  24. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +21 -2
  25. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +23 -0
  26. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +11 -1
  27. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +68 -23
  28. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +22 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +18 -5
  30. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +52 -8
  33. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +22 -6
  35. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +15 -4
  36. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +17 -2
  37. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +10 -4
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +10 -217
  41. package/src/runtime/agent/orchestrator/providers/cursor.mjs +7 -0
  42. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +409 -0
  43. package/src/runtime/agent/orchestrator/providers/devin-wire-families.mjs +187 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-identity.mjs +342 -0
  45. package/src/runtime/agent/orchestrator/providers/devin-wire-models.mjs +541 -0
  46. package/src/runtime/agent/orchestrator/providers/devin-wire-protobuf.mjs +305 -0
  47. package/src/runtime/agent/orchestrator/providers/devin-wire-request.mjs +558 -0
  48. package/src/runtime/agent/orchestrator/providers/devin-wire-schema.mjs +880 -0
  49. package/src/runtime/agent/orchestrator/providers/devin-wire-session.mjs +190 -0
  50. package/src/runtime/agent/orchestrator/providers/devin-wire-tool-args.mjs +351 -0
  51. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +560 -0
  52. package/src/runtime/agent/orchestrator/providers/devin.mjs +491 -0
  53. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/gemini-output-limit.mjs +46 -0
  55. package/src/runtime/agent/orchestrator/providers/gemini-reasoning-relay.mjs +30 -0
  56. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +7 -11
  57. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +61 -22
  58. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -0
  59. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -4
  60. package/src/runtime/agent/orchestrator/providers/gemini.mjs +25 -2
  61. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +96 -5
  63. package/src/runtime/agent/orchestrator/providers/lib/tail-reminder-split.mjs +23 -5
  64. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +17 -0
  65. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +39 -3
  66. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +2 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +6 -1
  69. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/providers/provider-availability.mjs +19 -0
  71. package/src/runtime/agent/orchestrator/providers/registry.mjs +17 -3
  72. package/src/runtime/agent/orchestrator/providers/wire-protobuf-codec.mjs +368 -0
  73. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  74. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +1 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs +133 -0
  76. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +23 -5
  78. package/src/runtime/agent/orchestrator/runtime-core/tool-profile.mjs +35 -10
  79. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +57 -10
  80. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +9 -6
  81. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  83. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +0 -3
  84. package/src/runtime/agent/orchestrator/session/agent-room.mjs +10 -4
  85. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +15 -3
  86. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -0
  87. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +36 -4
  88. package/src/runtime/agent/orchestrator/session/human-gate/outbound-policy.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/human-gate/store.mjs +15 -3
  90. package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +8 -0
  91. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  92. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +50 -2
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +3 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +11 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +11 -2
  97. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +60 -0
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +2 -0
  99. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +14 -0
  100. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +13 -0
  102. package/src/runtime/agent/orchestrator/session/message-ids.mjs +5 -3
  103. package/src/runtime/agent/orchestrator/session/message-reactions.mjs +89 -0
  104. package/src/runtime/agent/orchestrator/session/message-reactions.test-support.mjs +75 -0
  105. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +0 -2
  106. package/src/runtime/agent/orchestrator/session/routine-result.mjs +11 -2
  107. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +20 -2
  108. package/src/runtime/agent/orchestrator/session/store-agent-worker-header-cache.mjs +85 -0
  109. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +125 -12
  110. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +11 -5
  111. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +4 -0
  112. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -0
  113. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +7 -1
  114. package/src/runtime/channels/lib/schedule-errors.mjs +51 -0
  115. package/src/runtime/channels/lib/scheduler.mjs +109 -14
  116. package/src/runtime/channels/lib/schedules-db.mjs +49 -0
  117. package/src/runtime/channels/lib/tool-dispatch.mjs +13 -0
  118. package/src/runtime/channels/lib/worker-main.mjs +9 -0
  119. package/src/runtime/office/com/com-adapter.mjs +17 -1
  120. package/src/runtime/office/com/office-com-host.ps1 +51 -1
  121. package/src/runtime/office/pdf/cfb-fixture.mjs +328 -0
  122. package/src/runtime/office/pdf/cfb-protection.mjs +311 -0
  123. package/src/runtime/office/pdf/document-preview.mjs +326 -8
  124. package/src/runtime/office/pdf/ooxml-crypto.mjs +469 -0
  125. package/src/runtime/office/pdf/ooxml-key-worker.mjs +14 -0
  126. package/src/runtime/office/portable/portable-soffice.mjs +45 -5
  127. package/src/runtime/shared/bot-hops.mjs +62 -2
  128. package/src/runtime/shared/config.mjs +49 -22
  129. package/src/runtime/shared/connector-catalog.d.mts +4 -2
  130. package/src/runtime/shared/connector-catalog.mjs +16 -8
  131. package/src/runtime/shared/developer-options.mjs +32 -1
  132. package/src/runtime/shared/google-oauth-client.generated.json +4 -0
  133. package/src/runtime/shared/google-oauth-client.mjs +34 -9
  134. package/src/runtime/shared/llm/anthropic-betas.mjs +7 -0
  135. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +12 -0
  136. package/src/runtime/shared/llm/provider-catalog-registry.mjs +1 -0
  137. package/src/runtime/shared/llm/well-formed.mjs +66 -0
  138. package/src/runtime/shared/provider-accounts.mjs +224 -58
  139. package/src/runtime/shared/provider-api-key.mjs +19 -8
  140. package/src/runtime/shared/routine-trigger.mjs +42 -0
  141. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  142. package/src/runtime/shared/transcript-metadata.mjs +38 -0
  143. package/src/runtime/shared/user-data-guard.mjs +30 -2
  144. package/src/runtime/shared/worktrees/activity-gate.mjs +12 -5
  145. package/src/session-runtime/agent-home-delivery.mjs +57 -13
  146. package/src/session-runtime/agent-room-delivery.mjs +28 -1
  147. package/src/session-runtime/agent-task-sessions.mjs +292 -79
  148. package/src/session-runtime/boot/begin.mjs +3 -2
  149. package/src/session-runtime/boot/tools.mjs +2 -0
  150. package/src/session-runtime/bot-chain.mjs +32 -0
  151. package/src/session-runtime/bot-status.mjs +7 -4
  152. package/src/session-runtime/channel-config-api.mjs +63 -17
  153. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +45 -0
  154. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +72 -5
  155. package/src/session-runtime/internal-tool-executor.mjs +4 -0
  156. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -1
  157. package/src/session-runtime/mixy-heartbeat.mjs +807 -0
  158. package/src/session-runtime/model-route/route-tuning.mjs +10 -6
  159. package/src/session-runtime/owned-automation-run.mjs +33 -12
  160. package/src/session-runtime/provider-auth/login.mjs +7 -0
  161. package/src/session-runtime/provider-models/row-hydration.mjs +13 -2
  162. package/src/session-runtime/routine-home-turn.mjs +33 -6
  163. package/src/session-runtime/schedule-session-run.mjs +7 -3
  164. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +8 -12
  165. package/src/session-runtime/services/channel-admin.mjs +66 -18
  166. package/src/session-runtime/services/channel-worker.mjs +1 -1
  167. package/src/session-runtime/services/provider-admin.mjs +128 -25
  168. package/src/session-runtime/services/schedule-access.mjs +14 -0
  169. package/src/session-runtime/services/usage-dashboard-model.mjs +1 -0
  170. package/src/session-runtime/session-create/session-options.mjs +7 -4
  171. package/src/session-runtime/settings-system-api.mjs +16 -1
  172. package/src/session-runtime/setup-tool/executor.mjs +49 -14
  173. package/src/session-runtime/setup-tool/extended-actions.mjs +29 -3
  174. package/src/session-runtime/setup-tool/settings-contract.mjs +65 -4
  175. package/src/session-runtime/setup-tool/tool-defs.mjs +30 -3
  176. package/src/session-runtime/task-session-route.mjs +99 -0
  177. package/src/session-runtime/tool-policy/policy-stage.mjs +5 -2
  178. package/src/session-runtime/tool-policy/surface-helpers.mjs +2 -3
  179. package/src/session-runtime/tool-surface.mjs +4 -0
  180. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +98 -21
  181. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +6 -1
  182. package/src/session-runtime/workspace/transitions.mjs +10 -2
  183. package/src/standalone/daemon-session-control.mjs +88 -20
  184. package/src/standalone/daemon.mjs +8 -0
  185. package/src/standalone/session-protocol.mjs +5 -0
  186. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +6 -2
  187. package/src/standalone/session-service/session-calls/session-turns.mjs +6 -0
  188. package/src/standalone/session-service.mjs +13 -0
  189. package/src/standalone/session-tool-output.mjs +8 -5
  190. package/src/tui/app/model-options.mjs +2 -0
  191. package/src/tui/dist/index.mjs +24 -2
  192. package/src/tui/session/notice-surface.mjs +17 -1
  193. package/src/tui/session/session-api/integrations.mjs +1 -0
  194. package/src/tui/session/session-api/lifecycle.mjs +8 -0
  195. package/src/tui/session/session-api/settings.mjs +14 -2
  196. package/src/tui/session/turn-stream.mjs +17 -1
  197. package/src/tui/session/turn.mjs +22 -2
@@ -0,0 +1,89 @@
1
+ // session/message-reactions.mjs
2
+ // A bot's emoji reaction on a message the user wrote, shown under the user's
3
+ // bubble. It is stored on that message (`meta.transcript.reactions`, one
4
+ // `{ agentId, emoji, at }` per bot), so it travels with the transcript: a
5
+ // reload, a restart and every synced client read it from there. In a group
6
+ // room the message is the user's row in the room's log, and each member's
7
+ // reaction is kept beside the others'.
8
+ import { sessionMessageText } from '../runtime-core/session-text.mjs';
9
+ import { isInternalTranscriptDisplayText } from '../../../shared/tool-execution-contract.mjs';
10
+ import { normalizeMessageReactions } from '../../../shared/transcript-metadata.mjs';
11
+ import { isRoomRowMessage } from './agent-room.mjs';
12
+
13
+ /** Whether `message` is one the user wrote: a prompt typed (or steered) into
14
+ * the chat, or the user's own row in a room's log. Runtime notices, routine
15
+ * fires, posted results and other bots' messages are not. */
16
+ export function isReactableMessage(message) {
17
+ if (message?.role !== 'user' || !message.meta || typeof message.meta !== 'object') return false;
18
+ const { meta } = message;
19
+ const transcript = meta.transcript;
20
+ if (typeof transcript?.messageId !== 'string' || !transcript.messageId) return false;
21
+ if (isRoomRowMessage(message)) return meta.room.kind === 'user' && !meta.room.fromSession;
22
+ if ((meta.source && meta.source !== 'steering') || meta.synthetic === true) return false;
23
+ if (transcript.fromAgent || transcript.routineTrigger) return false;
24
+ return !isInternalTranscriptDisplayText(sessionMessageText(message.content));
25
+ }
26
+
27
+ /**
28
+ * Set `agentId`'s reaction (`emoji`; '' removes it) on the user message
29
+ * `messagesBack` user messages before the newest one in `messages`. One
30
+ * reaction per bot: a new one takes the place of the old. `othersKeep`: an
31
+ * emoji another bot already left there is refused (a group room's members do
32
+ * not repeat each other). Returns `{ previous, message, messageId, reactions,
33
+ * changed }`; `message` is a new object (the store's delta saves see a change
34
+ * by identity), placed by the caller. Throws when there is no such message.
35
+ */
36
+ export function reactToMessage(messages, { agentId, emoji, messagesBack = 0, othersKeep = false, at = Date.now() }) {
37
+ const list = Array.isArray(messages) ? messages : [];
38
+ let seen = 0;
39
+ let previous = null;
40
+ for (let index = list.length - 1; index >= 0; index -= 1) {
41
+ if (!isReactableMessage(list[index])) continue;
42
+ if (seen === messagesBack) {
43
+ previous = list[index];
44
+ break;
45
+ }
46
+ seen += 1;
47
+ }
48
+ if (!previous) {
49
+ throw new Error(
50
+ seen ? `react: there are only ${seen} user messages to react to` : 'react: there is no user message to react to'
51
+ );
52
+ }
53
+ const transcript = previous.meta.transcript;
54
+ const current = normalizeMessageReactions(transcript.reactions);
55
+ const own = current.find((entry) => entry.agentId === agentId);
56
+ if (emoji && othersKeep) {
57
+ const taken = current.find((entry) => entry.agentId !== agentId && entry.emoji === emoji);
58
+ if (taken) throw new Error(`react: ${taken.agentId} already left ${emoji} on that message`);
59
+ }
60
+ const messageId = transcript.messageId;
61
+ if (own?.emoji === emoji || (!own && !emoji)) {
62
+ return { previous, message: previous, messageId, reactions: current, changed: false };
63
+ }
64
+ const entry = { agentId, emoji, at };
65
+ let reactions;
66
+ if (!emoji) reactions = current.filter((each) => each.agentId !== agentId);
67
+ else if (own) reactions = current.map((each) => (each.agentId === agentId ? entry : each));
68
+ else reactions = [...current, entry];
69
+ const { reactions: _old, ...rest } = transcript;
70
+ const message = {
71
+ ...previous,
72
+ meta: { ...previous.meta, transcript: reactions.length ? { ...rest, reactions } : rest },
73
+ };
74
+ return { previous, message, messageId, reactions, changed: true };
75
+ }
76
+
77
+ /** reactToMessage on a stored session: the running turn's working transcript
78
+ * when a turn is in flight (its end commits that transcript), and the
79
+ * session's own message list, both holding the replaced message. */
80
+ export function reactInSession(session, change) {
81
+ const working = Array.isArray(session?.liveTurnMessages) ? session.liveTurnMessages : null;
82
+ const result = reactToMessage(working || session?.messages, change);
83
+ if (!result.changed) return result;
84
+ for (const list of [working, session.messages]) {
85
+ const index = Array.isArray(list) ? list.indexOf(result.previous) : -1;
86
+ if (index >= 0) list[index] = result.message;
87
+ }
88
+ return result;
89
+ }
@@ -0,0 +1,75 @@
1
+ // A real agent-loop turn in a bot's home chat whose model first calls `react`
2
+ // with `args`, then answers with empty text (and, if nudged, with 'Sure.').
3
+ // The react call runs through the real tool batch and the real react handler,
4
+ // so its tool result is the one a live turn would see.
5
+ import { agentLoop } from './agent-loop.mjs';
6
+ import { setInternalToolsProvider } from '../internal-tools.mjs';
7
+ import { REACTION_TOOL_DEFS } from '../runtime-core/reaction-tool-defs.mjs';
8
+ import { createReactionToolHandlers } from '../../../../session-runtime/internal-tool-executor/reaction-tool.mjs';
9
+
10
+ let scopeSeq = 0;
11
+
12
+ // `reply`: the text the model answers with after reacting (default none).
13
+ // `nextPrompt`: a second user turn run afterwards in the same session, whose
14
+ // model answers 'Next.' — the provider prefix guard carries across both.
15
+ export async function runReactionTurn(
16
+ args,
17
+ { onToolCall = null, onToolResult = () => {}, reply = '', nextPrompt = '' } = {}
18
+ ) {
19
+ const scopeId = `reaction-turn-${process.pid}-${++scopeSeq}`;
20
+ const prompt = { role: 'user', content: '고마워', meta: { transcript: { at: 1, messageId: 'msg_user_1' } } };
21
+ const session = {
22
+ id: 'sess_home_reviewer',
23
+ owner: 'cli',
24
+ agent: 'lead',
25
+ mcpScopeId: scopeId,
26
+ tools: [...REACTION_TOOL_DEFS],
27
+ messages: [prompt],
28
+ contextWindow: 200_000,
29
+ rawContextWindow: 200_000,
30
+ compaction: { auto: false },
31
+ };
32
+ const { react } = createReactionToolHandlers({ rt: { session }, live: () => null });
33
+ const dispose = setInternalToolsProvider({
34
+ scopeId,
35
+ tools: [...REACTION_TOOL_DEFS],
36
+ executor: (_name, toolArgs, callerCtx) => react(toolArgs, { callerCtx }),
37
+ });
38
+ const results = [];
39
+ let sends = 0;
40
+ const provider = {
41
+ async send() {
42
+ sends += 1;
43
+ if (sends === 1) return { content: '', toolCalls: [{ id: 'call_react', name: 'react', arguments: args }] };
44
+ if (sends > 3 || (reply && sends === 3)) return { content: 'Next.', toolCalls: [], stopReason: 'end_turn' };
45
+ return { content: sends === 2 ? reply : 'Sure.', toolCalls: [], stopReason: 'end_turn' };
46
+ },
47
+ };
48
+ const loopOpts = {
49
+ session,
50
+ sessionId: session.id,
51
+ onToolResult: (message) => {
52
+ results.push(message);
53
+ onToolResult(message);
54
+ },
55
+ };
56
+ try {
57
+ // The turn's working transcript is the loop's own list, as in a live turn.
58
+ const working = [prompt];
59
+ session.liveTurnMessages = working;
60
+ const result = await agentLoop(provider, working, 'fake-model', session.tools, onToolCall, process.cwd(), loopOpts);
61
+ session.messages = [...working];
62
+ delete session.liveTurnMessages;
63
+ let next = null;
64
+ if (nextPrompt) {
65
+ if (result.content) session.messages.push({ role: 'assistant', content: result.content });
66
+ session.messages.push({ role: 'user', content: nextPrompt });
67
+ session.liveTurnMessages = session.messages;
68
+ next = await agentLoop(provider, session.messages, 'fake-model', session.tools, null, process.cwd(), loopOpts);
69
+ }
70
+ const user = session.messages.find((message) => message.role === 'user');
71
+ return { result, next, sends, results, reactions: user?.meta?.transcript?.reactions || [] };
72
+ } finally {
73
+ dispose();
74
+ }
75
+ }
@@ -35,12 +35,10 @@ export interface RoutineNotice {
35
35
  }>;
36
36
  }
37
37
 
38
- export const ROUTINE_RESULT_SOURCE: 'schedule-result';
39
38
  export const TASK_LOOP_CAP: number;
40
39
  export function routineSummary(text: unknown): string;
41
40
  export const REPLY_LIMIT: number;
42
41
  export function boundedReply(text: unknown): { text: string; chars: number };
43
- export const WAKE_TEXT_BUDGET: number;
44
42
  export function routineResultMessage(options: {
45
43
  kind?: 'schedule' | 'webhook';
46
44
  name: string;
@@ -13,7 +13,7 @@ import { artifactLines, artifactRecords, normalizeHandoffFiles } from '../../../
13
13
  import { transcriptAttachmentFields } from '../../../shared/transcript-metadata.mjs';
14
14
  import { agentIdOfHomeSession } from './agent-home.mjs';
15
15
 
16
- export const ROUTINE_RESULT_SOURCE = 'schedule-result';
16
+ const ROUTINE_RESULT_SOURCE = 'schedule-result';
17
17
  const SUMMARY_LIMIT = 600;
18
18
 
19
19
  /** The one-line, bounded summary a routine result keeps of its run output. */
@@ -206,6 +206,8 @@ export function agentTaskMessage({
206
206
  files = [],
207
207
  omitted = 0,
208
208
  hop = 0,
209
+ sendId = '',
210
+ sendIds = [],
209
211
  at = Date.now(),
210
212
  messageId,
211
213
  }) {
@@ -260,6 +262,11 @@ export function agentTaskMessage({
260
262
  ...(agentIdOfHomeSession(taskSessionId) ? { agentId: agentIdOfHomeSession(taskSessionId) } : {}),
261
263
  // Bot-to-bot chain position of the send this answers (session-runtime/bot-hops.mjs).
262
264
  ...(hop > 0 ? { hop } : {}),
265
+ // The send this report answers (the send result's `sendId`).
266
+ ...(sendId ? { sendId } : {}),
267
+ // Every send of a turn that answered several (folded into one turn):
268
+ // the one report ends all of them, `sendId` being the first.
269
+ ...(sendIds.length > 1 ? { sendIds } : {}),
263
270
  ...(produced.length ? { files: produced } : {}),
264
271
  },
265
272
  transcript: { at, ...(messageId ? { messageId } : {}), ...(attachments.length ? { attachments } : {}) },
@@ -268,7 +275,7 @@ export function agentTaskMessage({
268
275
  }
269
276
 
270
277
  /** Reply text one wake message carries across all its reports. */
271
- export const WAKE_TEXT_BUDGET = 24000;
278
+ const WAKE_TEXT_BUDGET = 24000;
272
279
 
273
280
  /** A task report's content with its reply replaced by the row's one-line
274
281
  * summary and a pointer to the session; null for any other message. */
@@ -385,6 +392,8 @@ export function routineResultNotice(message) {
385
392
  sessionId,
386
393
  ...(session ? { target: 'session' } : {}),
387
394
  ...(agentId ? { agentId } : {}),
395
+ ...(routine.sendId ? { sendId: routine.sendId } : {}),
396
+ ...(routine.sendIds?.length > 1 ? { sendIds: routine.sendIds } : {}),
388
397
  ...(files.length ? { files } : {}),
389
398
  },
390
399
  // The stored files, in the field a prompt row draws its attachments from.
@@ -252,6 +252,10 @@ export async function sendWithRecovery(ctx) {
252
252
  retryAttemptNumber: Math.max(0, Number(transportRetriesUsed) || 0),
253
253
  retryMaxForDisplay,
254
254
  });
255
+ // The tail reminder this request carries; every assistant turn committed from
256
+ // its response stores it (turnReminder) so a provider in turn-scoped mode can
257
+ // re-send it verbatim before that turn (tail-reminder-split.mjs).
258
+ let turnReminder = '';
255
259
  try {
256
260
  let response;
257
261
  try {
@@ -266,22 +270,36 @@ export async function sendWithRecovery(ctx) {
266
270
  // Ephemeral tail reminder (tail-reminder-rules.mjs): added to this request's
267
271
  // copy only, never to the stored transcript.
268
272
  const requestMessages = withTailReminder(messages, { session: ctx.sessionRef, provider, model });
273
+ if (requestMessages !== messages) turnReminder = String(requestMessages.at(-1).content);
269
274
  response = await provider.send(requestMessages, model, sendTools.length ? sendTools : undefined, admittedOpts);
270
275
  hooks.logTurnTiming('ok');
271
276
  } catch (sendErr) {
272
277
  hooks.logTurnTiming(`err:${sendErr?.code || sendErr?.name || 'unknown'}`);
273
278
  stampStreamOutcome(sendErr, hooks.relayWitness);
274
- return await recoverFromSendError(sendErr, { ...ctx, relayWitness: hooks.relayWitness });
279
+ return withTurnReminder(
280
+ await recoverFromSendError(sendErr, { ...ctx, relayWitness: hooks.relayWitness }),
281
+ turnReminder
282
+ );
275
283
  }
276
284
  // streamedChars: what this send put on screen through onTextDelta (net of
277
285
  // acknowledged retractions), so the loop can withdraw it if it discards
278
286
  // the response and re-sends.
279
- return { action: 'proceed', response, streamedChars: hooks.relayWitness.text.length };
287
+ return withTurnReminder(
288
+ { action: 'proceed', response, streamedChars: hooks.relayWitness.text.length },
289
+ turnReminder
290
+ );
280
291
  } finally {
281
292
  hooks.restore();
282
293
  }
283
294
  }
284
295
 
296
+ // A proceeding response — complete, promoted incomplete, recovered tool calls
297
+ // or a cut kept as shown — was generated under this request's reminder.
298
+ function withTurnReminder(result, turnReminder) {
299
+ if (turnReminder && result?.action === 'proceed' && result.response) result.response.turnReminder = turnReminder;
300
+ return result;
301
+ }
302
+
285
303
  function loopTag({ sessionId, nextIteration }) {
286
304
  return `sess=${sessionId || 'unknown'} iter=${nextIteration}`;
287
305
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Persisted copy of the agent pool's session-header cache. The in-memory
3
+ * cache of store-agent-worker-rows.mjs died with the daemon, so the first
4
+ * pool listing after every restart re-read every worker transcript (~1,700
5
+ * records, ~1 GB) just to rebuild headers that had not changed: seconds of
6
+ * I/O and CPU right when the restored conversation was loading.
7
+ *
8
+ * The file only seeds the in-memory cache. Every seeded entry is still served
9
+ * under that cache's full-stamp policy — the record must still have the exact
10
+ * { dev, ino, size, mtimeNs, ctimeNs } its header was read from, a stamp that
11
+ * was settled when cached — so a record changed while no daemon ran (or by
12
+ * another process) is read again. Being a cache, an absent, unreadable,
13
+ * damaged, other-version or other-field-set file seeds nothing (a bad entry
14
+ * alone is skipped) and is replaced by the next write; writes are atomic.
15
+ */
16
+ import { randomBytes } from 'node:crypto';
17
+ import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import { sessionStampOf } from './store/canonical-reader.mjs';
20
+ import { isStoredSessionId } from './store-summary-fields.mjs';
21
+ import { dataDir } from './store-summary-locations.mjs';
22
+
23
+ const VERSION = 1;
24
+ const STAMP_KEYS = ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'];
25
+ const UNSIGNED = /^\d+$/;
26
+
27
+ export function storedAgentWorkerHeaderCachePath(dir = dataDir()) {
28
+ return join(dir, 'agent-worker-header-cache.json');
29
+ }
30
+
31
+ function decodeStamp(value) {
32
+ if (!Array.isArray(value) || value.length !== STAMP_KEYS.length) return null;
33
+ if (!value.every((part) => typeof part === 'string' && UNSIGNED.test(part))) return null;
34
+ return sessionStampOf(Object.fromEntries(STAMP_KEYS.map((key, index) => [key, BigInt(value[index])])));
35
+ }
36
+
37
+ function decodeHeader(value, fields) {
38
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
39
+ return Object.keys(value).every((key) => fields.has(key)) ? value : null;
40
+ }
41
+
42
+ /** sessionId -> { stamp, header } from the file at `path`; empty when the
43
+ * file is absent, unreadable, damaged, or written for another version or
44
+ * header field list. */
45
+ export async function readPersistedWorkerHeaders(path, fields) {
46
+ const entries = new Map();
47
+ let doc;
48
+ try {
49
+ doc = JSON.parse(await readFile(path, 'utf8'));
50
+ } catch {
51
+ return entries;
52
+ }
53
+ if (doc?.version !== VERSION || doc.fields !== fields.join(',')) return entries;
54
+ if (!doc.sessions || typeof doc.sessions !== 'object' || Array.isArray(doc.sessions)) return entries;
55
+ const wanted = new Set(fields);
56
+ for (const [sessionId, entry] of Object.entries(doc.sessions)) {
57
+ if (!isStoredSessionId(sessionId) || !entry || typeof entry !== 'object') continue;
58
+ const stamp = decodeStamp(entry.stamp);
59
+ const header = decodeHeader(entry.header, wanted);
60
+ if (stamp && header) entries.set(sessionId, { stamp, header });
61
+ }
62
+ return entries;
63
+ }
64
+
65
+ /** Replaces the file at `path` with `entries` (sessionId -> { stamp, header })
66
+ * by rename, so a reader sees the old file or the new one, never a torn one.
67
+ * Resolves false instead of throwing: a lost write only costs the next
68
+ * restart some re-reads. The data directory is never created: a store that
69
+ * is gone gets no cache. */
70
+ export async function writePersistedWorkerHeaders(path, fields, entries) {
71
+ const sessions = {};
72
+ for (const [sessionId, { stamp, header }] of entries) {
73
+ sessions[sessionId] = { stamp: STAMP_KEYS.map((key) => String(stamp[key])), header };
74
+ }
75
+ // The temp shape is atomic-file.mjs's `<name>.mixdog-tmp-<hex>`.
76
+ const tmp = `${path}.mixdog-tmp-${randomBytes(8).toString('hex')}`;
77
+ try {
78
+ await writeFile(tmp, JSON.stringify({ version: VERSION, fields: fields.join(','), sessions }), { flag: 'wx' });
79
+ await rename(tmp, path);
80
+ return true;
81
+ } catch {
82
+ await unlink(tmp).catch(() => {});
83
+ return false;
84
+ }
85
+ }
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { readFileSync } from 'node:fs';
19
19
  import { readFile, stat } from 'node:fs/promises';
20
- import { join } from 'node:path';
20
+ import { basename, join } from 'node:path';
21
21
  import {
22
22
  isSettledSessionStamp,
23
23
  sameSessionStamp,
@@ -27,6 +27,11 @@ import {
27
27
  import { readJsonProjection } from '../../../shared/pending-json-projection.mjs';
28
28
  import { listStoredAgentWorkers as assembleStoredAgentWorkers } from './store-agent-worker-pool.mjs';
29
29
  import { readTopLevelFields } from './store-agent-worker-header.mjs';
30
+ import {
31
+ readPersistedWorkerHeaders,
32
+ storedAgentWorkerHeaderCachePath,
33
+ writePersistedWorkerHeaders,
34
+ } from './store-agent-worker-header-cache.mjs';
30
35
  import {
31
36
  TAG_TOMBSTONE_TTL_MS,
32
37
  agentPaneVisible,
@@ -330,8 +335,104 @@ const WORKER_HEADER_FIELDS = [
330
335
  // assembly the first answer stands, absent or unreadable included.
331
336
  let passHeaders = null; // path -> header | null, live only during an assembly
332
337
 
333
- function workerSessionHeaderPath(sessionId) {
334
- return join(dataDir(), 'sessions', `${sessionId}.json`);
338
+ function workerSessionHeaderPath(sessionId, dir = dataDir()) {
339
+ return join(dir, 'sessions', `${sessionId}.json`);
340
+ }
341
+
342
+ // Every change to the header cache is counted, so the persisted copy is
343
+ // rewritten only when the cache it mirrors moved.
344
+ let headerCacheEdits = 0;
345
+
346
+ function cacheHeader(path, entry) {
347
+ workerSessionHeaderCache.set(path, entry);
348
+ headerCacheEdits += 1;
349
+ }
350
+
351
+ function dropHeader(path) {
352
+ if (workerSessionHeaderCache.delete(path)) headerCacheEdits += 1;
353
+ }
354
+
355
+ // The header cache outlives the daemon through store-agent-worker-header-
356
+ // cache.mjs: the first awaited listing of a data directory seeds the cache
357
+ // from its file (entries already in memory win), and an awaited listing that
358
+ // changed the cache schedules a rewrite. The write waits out
359
+ // HEADER_PERSIST_DELAY_MS — off the boot path, and at most one write per
360
+ // window however often agents churn. Seeded entries pass the same full-stamp
361
+ // check as any other, so a seed never serves a header its file no longer has.
362
+ const HEADER_PERSIST_DELAY_MS = 10_000;
363
+ let seededDir = null;
364
+ let seeding = null;
365
+ // The edit count the last write left on disk, and the directory it went to:
366
+ // a count persisted for one data directory says nothing about another.
367
+ let persistedEdits = 0;
368
+ let persistedDir = null;
369
+ let persistTimer = null;
370
+ let persistTimerDir = null;
371
+ let persistWrite = null;
372
+
373
+ function seedPersistedHeaders() {
374
+ const dir = dataDir();
375
+ if (seededDir !== dir) {
376
+ seededDir = dir;
377
+ seeding = readPersistedWorkerHeaders(storedAgentWorkerHeaderCachePath(dir), WORKER_HEADER_FIELDS).then(
378
+ (entries) => {
379
+ for (const [sessionId, { stamp, header }] of entries) {
380
+ const path = workerSessionHeaderPath(sessionId, dir);
381
+ if (!workerSessionHeaderCache.has(path)) workerSessionHeaderCache.set(path, { stamp, header, pass: 0 });
382
+ }
383
+ }
384
+ );
385
+ }
386
+ return seeding;
387
+ }
388
+
389
+ function schedulePersistHeaders() {
390
+ const dir = dataDir();
391
+ // A write pending for the directory this listing left is dropped: the
392
+ // listing just forgot that directory's unread headers, so writing it now
393
+ // would shrink its file. Its last written cache still stands.
394
+ if (persistTimer && persistTimerDir !== dir) {
395
+ clearTimeout(persistTimer);
396
+ persistTimer = null;
397
+ }
398
+ if (persistTimer || (persistedDir === dir && headerCacheEdits === persistedEdits)) return;
399
+ persistTimerDir = dir;
400
+ persistTimer = setTimeout(() => {
401
+ persistTimer = null;
402
+ persistHeaders(persistTimerDir);
403
+ }, HEADER_PERSIST_DELAY_MS);
404
+ persistTimer.unref?.();
405
+ }
406
+
407
+ function persistHeaders(dir) {
408
+ if (persistWrite) return persistWrite;
409
+ const edits = headerCacheEdits;
410
+ const entries = [];
411
+ for (const [path, entry] of workerSessionHeaderCache) {
412
+ const sessionId = basename(path, '.json');
413
+ if (isStoredSessionId(sessionId) && workerSessionHeaderPath(sessionId, dir) === path) {
414
+ entries.push([sessionId, entry]);
415
+ }
416
+ }
417
+ persistWrite = writePersistedWorkerHeaders(storedAgentWorkerHeaderCachePath(dir), WORKER_HEADER_FIELDS, entries).then(
418
+ (written) => {
419
+ if (written) {
420
+ persistedEdits = edits;
421
+ persistedDir = dir;
422
+ }
423
+ persistWrite = null;
424
+ }
425
+ );
426
+ return persistWrite;
427
+ }
428
+
429
+ /** Test/diagnostic seam: writes a scheduled header-cache persist now and
430
+ * resolves once no write is in flight. */
431
+ export function flushStoredAgentWorkerHeaderCache() {
432
+ if (!persistTimer) return persistWrite || Promise.resolve();
433
+ clearTimeout(persistTimer);
434
+ persistTimer = null;
435
+ return persistHeaders(persistTimerDir);
335
436
  }
336
437
 
337
438
  function readWorkerSessionHeader(sessionId) {
@@ -358,15 +459,18 @@ function observeStamp(path) {
358
459
  // window when read. Size + mtime alone let an equal-length rewrite that kept
359
460
  // its mtime serve stale ownership, status and cancel fields.
360
461
  function probeWorkerSessionHeader(path) {
361
- // Absent and unreadable alike: no header.
462
+ // Absent and unreadable alike: no header, and no cached one kept for it.
362
463
  const before = observeStamp(path);
363
- if (!before) return null;
464
+ if (!before) {
465
+ dropHeader(path);
466
+ return null;
467
+ }
364
468
  const cached = workerSessionHeaderCache.get(path);
365
469
  if (cached && sameSessionStamp(cached.stamp, before)) {
366
470
  cached.pass = headerPass;
367
471
  return cached.header;
368
472
  }
369
- workerSessionHeaderCache.delete(path);
473
+ dropHeader(path);
370
474
  // A rewrite re-reads only this record, and only its top-level fields are
371
475
  // decoded: the transcript bytes are validated, never materialized.
372
476
  let header = null;
@@ -378,7 +482,7 @@ function probeWorkerSessionHeader(path) {
378
482
  if (!header) return null;
379
483
  workerHeaderReads += 1;
380
484
  if (isSettledSessionStamp(before) && sameSessionStamp(before, observeStamp(path))) {
381
- workerSessionHeaderCache.set(path, { stamp: before, header, pass: headerPass });
485
+ cacheHeader(path, { stamp: before, header, pass: headerPass });
382
486
  }
383
487
  return header;
384
488
  }
@@ -395,10 +499,13 @@ async function observeStampAsync(path) {
395
499
  * same full-stamp cache policy. */
396
500
  async function probeWorkerSessionHeaderAsync(path) {
397
501
  const before = await observeStampAsync(path);
398
- if (!before) return null;
502
+ if (!before) {
503
+ dropHeader(path);
504
+ return null;
505
+ }
399
506
  const cached = workerSessionHeaderCache.get(path);
400
507
  if (cached && sameSessionStamp(cached.stamp, before)) return cached.header;
401
- workerSessionHeaderCache.delete(path);
508
+ dropHeader(path);
402
509
  let header = null;
403
510
  try {
404
511
  header = readTopLevelFields(await readFile(path), WORKER_HEADER_FIELDS);
@@ -408,7 +515,7 @@ async function probeWorkerSessionHeaderAsync(path) {
408
515
  if (!header) return null;
409
516
  workerHeaderReads += 1;
410
517
  if (isSettledSessionStamp(before) && sameSessionStamp(before, await observeStampAsync(path))) {
411
- workerSessionHeaderCache.set(path, { stamp: before, header, pass: headerPass });
518
+ cacheHeader(path, { stamp: before, header, pass: headerPass });
412
519
  }
413
520
  return header;
414
521
  }
@@ -418,7 +525,9 @@ async function probeWorkerSessionHeaderAsync(path) {
418
525
  const HEADER_READ_CONCURRENCY = 4;
419
526
 
420
527
  async function prefetchWorkerHeaders(sessionIds, headers) {
421
- const paths = [...new Set([...sessionIds].map(workerSessionHeaderPath))].filter((path) => !headers.has(path));
528
+ const paths = [...new Set([...sessionIds].map((sessionId) => workerSessionHeaderPath(sessionId)))].filter(
529
+ (path) => !headers.has(path)
530
+ );
422
531
  let next = 0;
423
532
  const drain = async () => {
424
533
  while (next < paths.length) {
@@ -634,7 +743,7 @@ export function listStoredAgentWorkers({ runningSessionIds = null } = {}) {
634
743
  // Keep what the latest assembly read, whatever the index size; forget the rest.
635
744
  function forgetUnreadHeaders() {
636
745
  for (const [path, entry] of workerSessionHeaderCache) {
637
- if (entry.pass !== headerPass) workerSessionHeaderCache.delete(path);
746
+ if (entry.pass !== headerPass) dropHeader(path);
638
747
  }
639
748
  }
640
749
 
@@ -643,6 +752,8 @@ function forgetUnreadHeaders() {
643
752
  * each Lead row's, and each child's owner) are read first with awaited
644
753
  * stats and reads; the assembly then runs over what was read, with no file
645
754
  * access left in it (any header it still asks for falls back to a read).
755
+ * The first listing after a restart seeds the header cache from its
756
+ * persisted copy, so only records changed since are read again.
646
757
  * `runningSessionIds` may be a function, asked once the reads are done. */
647
758
  export async function listStoredAgentWorkersAsync({ runningSessionIds = null } = {}) {
648
759
  const agentIndexPath = storedAgentWorkerIndexPath();
@@ -650,6 +761,7 @@ export async function listStoredAgentWorkersAsync({ runningSessionIds = null } =
650
761
  const [agentIndex, leadIndex] = await Promise.all([
651
762
  readWorkerIndexFileAsync(agentIndexPath),
652
763
  readWorkerIndexFileAsync(leadIndexPath),
764
+ seedPersistedHeaders(),
653
765
  ]);
654
766
  const headers = new Map();
655
767
  const childRows = workerRows(agentIndex);
@@ -683,6 +795,7 @@ export async function listStoredAgentWorkersAsync({ runningSessionIds = null } =
683
795
  passHeaders = null;
684
796
  passIndexes = null;
685
797
  forgetUnreadHeaders();
798
+ schedulePersistHeaders();
686
799
  }
687
800
  }
688
801
 
@@ -1,8 +1,14 @@
1
1
  // Tail reminders: short per-model/per-language nudges appended to the very end
2
2
  // of an outgoing request (after the last tool result), fresh on every request.
3
- // They are never stored in session history or transcripts, and the Anthropic
4
- // builders place their last cache breakpoint BEFORE them, so the cached prefix
5
- // stays byte-identical from one request to the next.
3
+ // They are never stored as messages of their own; the Anthropic builders place
4
+ // their last cache breakpoint BEFORE them. The assistant turn a request produces
5
+ // keeps that request's reminder text (`turnReminder`, send-with-recovery.mjs).
6
+ // How each provider places them (providers/lib/tail-reminder-split.mjs):
7
+ // - legacy: the reminder ends this request only and is gone from the next;
8
+ // - turn-scoped (first-party Anthropic on a supported model, opt-in per
9
+ // provider): a clear_at system message that every later request re-sends
10
+ // verbatim before its assistant turn, so the wire is append-only and the
11
+ // model's earlier thinking stays bound to an unchanged prefix.
6
12
  //
7
13
  // HOW TO ADD A RULE (a new per-model/per-language fix needs nothing else):
8
14
  // 1. Append {id, when, text} to TAIL_REMINDER_RULES. `when` holds any of
@@ -17,7 +23,7 @@ import { AGENT_MODE } from './agent-mode.mjs';
17
23
  import { _buildLeadLanguage } from './manager/rules-cache.mjs';
18
24
  import { TAIL_REMINDER_META } from '../providers/lib/tail-reminder-split.mjs';
19
25
 
20
- export const TAIL_REMINDER_TAGS = Object.freeze(['provider', 'model', 'language', 'role']);
26
+ const TAIL_REMINDER_TAGS = Object.freeze(['provider', 'model', 'language', 'role']);
21
27
  const ROLES = ['lead', 'bot', 'worker'];
22
28
 
23
29
  const PROVIDER_FAMILIES = Object.freeze({
@@ -84,7 +90,7 @@ export function modelFamilyId(model) {
84
90
  }
85
91
 
86
92
  /** The session's role: 'worker' (agent-owned), 'bot' (agent mode) or 'lead'. */
87
- export function tailReminderRole(session) {
93
+ function tailReminderRole(session) {
88
94
  if (session?.owner === 'agent') return 'worker';
89
95
  return session?.sessionMode === AGENT_MODE ? 'bot' : 'lead';
90
96
  }
@@ -33,6 +33,10 @@ export function createBatchState(ctx) {
33
33
  pushToolResultMessage: ctx.pushToolResultMessage,
34
34
  throwIfAborted: ctx.throwIfAborted,
35
35
  repeatFailLimit: ctx.repeatFailLimit,
36
+ // Calls of this batch a capped turn may still run (session/loop/turn-limits.mjs).
37
+ callsAllowed: Number.isFinite(ctx.callsAllowed) ? ctx.callsAllowed : Infinity,
38
+ // After this time (epoch ms) no call of the batch starts.
39
+ wrapUpAt: Number.isFinite(ctx.wrapUpAt) ? ctx.wrapUpAt : Infinity,
36
40
  toolBatchId: ctx.toolBatchId,
37
41
  executeToolFn: typeof ctx.executeToolFn === 'function' ? ctx.executeToolFn : executeTool,
38
42
  dedupStubTotal: ctx.dedupStubTotal,
@@ -12,6 +12,7 @@ import { executeBatchCall } from './tool-batch/execute-call.mjs';
12
12
  import { recordCallOutcome } from './tool-batch/record-outcome.mjs';
13
13
  import { finalizeBatchResults } from './tool-batch/finalize.mjs';
14
14
  import { flushBatch } from './tool-batch/flush.mjs';
15
+ import { turnLimitRefusal } from './loop/turn-limits.mjs';
15
16
 
16
17
  export async function processToolBatch(ctx) {
17
18
  const batch = createBatchState(ctx);
@@ -19,6 +20,10 @@ export async function processToolBatch(ctx) {
19
20
  for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
20
21
  const call = calls[callIndex];
21
22
  if (isBuiltinTool(call.name)) call.name = canonicalizeBuiltinToolName(call.name);
23
+ if (callIndex >= batch.callsAllowed || Date.now() >= batch.wrapUpAt) {
24
+ stageToolResult(batch, turnLimitRefusal(call));
25
+ continue;
26
+ }
22
27
  const { skip, ctSig, sigs } = preDispatchSkip(batch, call);
23
28
  if (skip !== null) {
24
29
  stageToolResult(batch, skip);