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
@@ -14,17 +14,22 @@
14
14
  // allowed, payments and permanent deletes denied. Group-room members register
15
15
  // a port here (session-runtime/held-requests.mjs) that turns their asks into
16
16
  // held requests when no approval card is attached.
17
+ // The "Allow all actions" developer option (shared/developer-options.mjs)
18
+ // skips every ask below, the bot's per-action `ask` mode and a hook `ask`
19
+ // included, except a payment or permanent delete; a `block` and a hook `deny`
20
+ // still deny.
17
21
  import { MIXY_AGENT_ID, agentIdOfHomeSession } from '../agent-home.mjs';
18
22
  import { serverConnector } from '../../mcp/server-connectors.mjs';
19
23
  import { readAgentDefinitionKind } from '../../../../shared/agent-kind.mjs';
20
24
  import { isContactAgentId } from '../../../../shared/agent-route-config.mjs';
25
+ import { developerOptionEnabledStrict } from '../../../../shared/developer-options.mjs';
21
26
  import { mixdogRoot, resolvePluginData } from '../../../../shared/plugin-paths.mjs';
22
27
  import { roomMemberOfSession } from '../agent-room.mjs';
23
28
  import { agentDisplayName } from '../../context/role-instructions.mjs';
24
29
  import { delegationOf } from './delegation.mjs';
25
30
  import { setUnattendedApprovalPort, unattendedApprovalPort } from './unattended-port.mjs';
26
- import { classifyAsDelete, classifyOutboundCall, isRememberableAction } from './outbound-policy.mjs';
27
- import { humanGateStore } from './store.mjs';
31
+ import { classifyAsDelete, classifyOutboundCall, isAlwaysAskAction, isRememberableAction } from './outbound-policy.mjs';
32
+ import { DEV_ALLOW_ALL_DECISION, humanGateStore } from './store.mjs';
28
33
  import {
29
34
  AUTOMATION_GRANT_REASON,
30
35
  RUN_FORBIDDEN_REASON,
@@ -33,7 +38,7 @@ import {
33
38
  savesAutomation,
34
39
  } from './run-policy.mjs';
35
40
 
36
- export const OUTBOUND_APPROVAL_REASON = 'This sends or posts outside Mixdog; it needs your approval.';
41
+ const OUTBOUND_APPROVAL_REASON = 'This sends or posts outside Mixdog; it needs your approval.';
37
42
  export const BLOCKED_ACTION_REASON = "This agent's permission blocks this action.";
38
43
  /** Outbound grants may be remembered for this agent, never for every agent;
39
44
  * payments and deletes never. */
@@ -105,6 +110,22 @@ export function approvalSurface(sessionId) {
105
110
 
106
111
  const actionOf = (decision) => String(decision?.action || decision?.decision || '').toLowerCase();
107
112
 
113
+ /** Whether the "Allow all actions" developer option is on (default off).
114
+ * Fail-closed: an unreadable, missing or malformed config is off (the call
115
+ * asks), and a value restored from a backup never counts. */
116
+ export const allowAllActionsEnabled = () => developerOptionEnabledStrict('allowAllActions');
117
+
118
+ /** Whether the option lets a call of this classification (or none) run without
119
+ * a card: every ask, never a classified payment or permanent delete. */
120
+ const skipsApproval = (classified) => !isAlwaysAskAction(classified?.category) && allowAllActionsEnabled();
121
+
122
+ /** The hook decision once its card is skipped: an `ask` runs as an allow,
123
+ * keeping any rewrite it carried. */
124
+ function withoutAsk(decision) {
125
+ if (actionOf(decision) !== 'ask') return decision;
126
+ return { ...decision, action: decision.name || decision.args ? 'modify' : 'allow' };
127
+ }
128
+
108
129
  async function mcpAnnotations(name, scopeId) {
109
130
  if (!String(name).startsWith('mcp__')) return undefined;
110
131
  try {
@@ -185,11 +206,17 @@ export async function humanGateDecision(call, decision, ctx = {}) {
185
206
  const outbound = classified && actingBotId(ctx.sessionId) ? classified : null;
186
207
  if (!outbound) {
187
208
  if (action !== 'ask') return { decision, request: null, gate: null };
209
+ const gate = { agentId, kind: 'approval', tool: String(call?.name || ''), target: '' };
210
+ if (skipsApproval(classified)) {
211
+ const store = ctx.store || (await humanGateStore());
212
+ await store.recordAudit({ ...gate, decision: DEV_ALLOW_ALL_DECISION, device: '' });
213
+ return { decision: withoutAsk(decision), request: null, gate: null };
214
+ }
188
215
  return {
189
216
  // Saving a schedule or webhook says plainly what the approval grants.
190
217
  decision: savesAutomation(call) ? { ...decision, reason: AUTOMATION_GRANT_REASON } : decision,
191
218
  request: { kind: 'approval', agentId, remember: ['once'] },
192
- gate: { agentId, kind: 'approval', tool: String(call?.name || ''), target: '' },
219
+ gate,
193
220
  };
194
221
  }
195
222
  const gate = { agentId, kind: 'outbound', action: outbound.category, tool: outbound.tool, target: outbound.target };
@@ -215,6 +242,11 @@ export async function humanGateDecision(call, decision, ctx = {}) {
215
242
  await store.recordAudit({ ...gate, decision: 'blocked', device: '' });
216
243
  return { decision: { ...base, action: 'deny', reason: BLOCKED_ACTION_REASON }, request: null, gate: null };
217
244
  }
245
+ // The bot's `ask` mode (and a hook's `ask`) is bypassed; a payment or delete is not.
246
+ if (skipsApproval(outbound)) {
247
+ await store.recordAudit({ ...gate, decision: DEV_ALLOW_ALL_DECISION, device: '' });
248
+ return { decision: withoutAsk(decision), request: null, gate: null };
249
+ }
218
250
  if (action !== 'ask' && mode === 'allow') {
219
251
  await store.recordAudit({ ...gate, decision: 'remembered', device: '' });
220
252
  // The remembered grant answers this call: no later layer asks it again.
@@ -39,6 +39,12 @@ export function isRememberableAction(kind) {
39
39
  return ACTION_KINDS.includes(kind) && !UNREMEMBERED_ACTIONS.has(kind);
40
40
  }
41
41
 
42
+ /** Whether `kind` is a payment or permanent delete: it asks every time,
43
+ * whatever else is allowed (also by the "Allow all actions" developer option). */
44
+ export function isAlwaysAskAction(kind) {
45
+ return UNREMEMBERED_ACTIONS.has(kind);
46
+ }
47
+
42
48
  const GITHUB_OUTBOUND = new Map([
43
49
  ['issue.comment', 'comment'],
44
50
  ['pr.comment', 'comment'],
@@ -17,7 +17,7 @@ import { ACTION_KINDS, isRememberableAction } from './outbound-policy.mjs';
17
17
  const ANY_AGENT = '*';
18
18
  const FIELD_LIMIT = 200;
19
19
  const AUDIT_KEYS = ['agentId', 'kind', 'action', 'tool', 'target', 'decision', 'device'];
20
- export const PERMISSION_MODES = Object.freeze(['ask', 'allow', 'block']);
20
+ const PERMISSION_MODES = Object.freeze(['ask', 'allow', 'block']);
21
21
 
22
22
  const field = (value) => String(value ?? '').slice(0, FIELD_LIMIT);
23
23
  const grantId = (agentId, action) =>
@@ -173,12 +173,24 @@ export async function setAgentPermission(agentId, action, mode, options) {
173
173
  return store.permissions(agent);
174
174
  }
175
175
 
176
- const BROWSER_AUDIT_DECISIONS = Object.freeze(['approve', 'deny', 'granted', 'remembered', 'run-allowed', 'blocked']);
176
+ /** The audit decision of a call the "Allow all actions" developer option let through unasked. */
177
+ export const DEV_ALLOW_ALL_DECISION = 'dev-allow-all';
178
+
179
+ const BROWSER_AUDIT_DECISIONS = Object.freeze([
180
+ 'approve',
181
+ 'deny',
182
+ 'granted',
183
+ 'remembered',
184
+ 'run-allowed',
185
+ 'blocked',
186
+ DEV_ALLOW_ALL_DECISION,
187
+ ]);
177
188
 
178
189
  /** Audit one Browser Use approval of a bot (capability recordAgentApprovalAudit):
179
190
  * the bot, the action kind and what happened — `approve` / `deny` for the card,
180
191
  * `granted` when "always allow" was saved, `remembered` when a saved allow let
181
- * a step run unasked. Never a page address, target or value. */
192
+ * a step run unasked, `dev-allow-all` when the developer option did. Never a
193
+ * page address, target or value. */
182
194
  export async function recordAgentApprovalAudit(agentId, action, decision) {
183
195
  const agent = requiredAgentId(agentId);
184
196
  if (!ACTION_KINDS.includes(action)) throw new Error(`action must be one of ${ACTION_KINDS.join(', ')}`);
@@ -62,6 +62,12 @@ function assistantEchoFields(payloads) {
62
62
  };
63
63
  }
64
64
 
65
+ // The tail reminder the response was generated under (send-with-recovery.mjs),
66
+ // kept verbatim so a turn-scoped provider replays it byte-identically.
67
+ export function turnReminderField(resp) {
68
+ return typeof resp?.turnReminder === 'string' && resp.turnReminder ? { turnReminder: resp.turnReminder } : {};
69
+ }
70
+
65
71
  // Intermediate (non-tool) assistant turn: max-output partials, refusal
66
72
  // narration, provider continuations, and the terminal turn committed before a
67
73
  // steering drain. Returns null when the turn carries nothing committable — a
@@ -89,6 +95,7 @@ export function buildIntermediateAssistantMessage(resp, opts) {
89
95
  role: 'assistant',
90
96
  content,
91
97
  ...assistantEchoFields(payloads),
98
+ ...turnReminderField(resp),
92
99
  ...(stopReason ? { stopReason } : {}),
93
100
  ...(terminationReason ? { terminationReason } : {}),
94
101
  },
@@ -108,6 +115,7 @@ export function buildToolCallAssistantMessage(response, { calls, opts }) {
108
115
  content: response.content || '',
109
116
  toolCalls: compactToolCallsForHistory(calls, { deferBodies: true }),
110
117
  ...assistantEchoFields(assistantEchoPayloads(response, { keepAssistantBlocks: true })),
118
+ ...turnReminderField(response),
111
119
  },
112
120
  opts
113
121
  );
@@ -55,6 +55,9 @@ export function createLoopState({ provider, messages, model, tools, cwd, sendOpt
55
55
  cwd: resolveLiveToolCwd(cwd, sessionRef),
56
56
  iterations: 0,
57
57
  toolCallsTotal: 0,
58
+ // A capped turn (turn-limits.mjs): told to reply, and ended by calling tools anyway.
59
+ turnLimitNoticed: false,
60
+ turnLimitEnded: false,
58
61
  lastUsage: undefined,
59
62
  firstTurnUsage: undefined,
60
63
  response: undefined,
@@ -147,6 +150,9 @@ export function createLoopState({ provider, messages, model, tools, cwd, sendOpt
147
150
  * promote an empty/abnormal finish to an explicit Lead-facing error). */
148
151
  export function finishLoop(state) {
149
152
  const { response } = state;
153
+ // A reaction-only turn is a successful completion, never an empty finish.
154
+ const terminationReason = classifyTerminationReason(response, { sessionAgent: state.sessionAgent });
155
+ const reactionCompleted = terminationReason === 'empty' && state.noToolTurn.reactionCompleted;
150
156
  return {
151
157
  ...response,
152
158
  usage: state.lastUsage || response.usage,
@@ -157,7 +163,8 @@ export function finishLoop(state) {
157
163
  toolCallsTotal: state.toolCallsTotal,
158
164
  providerState: state.providerState,
159
165
  providerStateUpdated: state.providerStateUpdated,
160
- terminationReason: classifyTerminationReason(response, { sessionAgent: state.sessionAgent }),
166
+ terminationReason: reactionCompleted ? undefined : terminationReason,
167
+ ...(reactionCompleted ? { reactionCompleted: true } : {}),
161
168
  providerContinuations: state.noToolTurn.providerContinuations,
162
169
  ...(state.routeUsageHops?.length
163
170
  ? {
@@ -20,8 +20,11 @@ export function createTurnSegments({ messages, opts }) {
20
20
  // Record a mid-turn segment for the caller-facing aggregate and surface it
21
21
  // live for every session.
22
22
  // `committed: true` marks text that is the just-committed message's own.
23
+ // A committed message is whole, so the text after it is another message
24
+ // and a break keeps the aggregate from running the two together; a part
25
+ // that was cut off (max-output recovery) resumes right where it stopped.
23
26
  record(text, { committed = false } = {}) {
24
- parts.push(text);
27
+ parts.push(committed ? `${text.trimEnd()}\n\n` : text);
25
28
  try {
26
29
  opts.onAssistantText?.(text, { committed });
27
30
  } catch {
@@ -18,6 +18,9 @@ import { resetOutputLimitRecovery, resolveOutputLimit } from './no-tool-turn/out
18
18
  import { resolveRefusal } from './no-tool-turn/refusal.mjs';
19
19
  import { resumeContinuation } from './no-tool-turn/continuation.mjs';
20
20
  import { nudgeEmptyTurn } from './no-tool-turn/empty-nudge.mjs';
21
+ import { isReactionSuccess, reactionCallIds, takeReactionTarget } from '../../runtime-core/reaction-tool-defs.mjs';
22
+ import { messageIdOf } from '../message-ids.mjs';
23
+ import { isReactableMessage } from '../message-reactions.mjs';
21
24
 
22
25
  // `escalateMaxTokens()` asks the live provider for a higher one-shot max_tokens
23
26
  // (number) or reports none (null/undefined) — the loop stays provider-agnostic.
@@ -37,6 +40,9 @@ export function createNoToolTurnResolver({
37
40
  outputEscalationUsed: false,
38
41
  refusalRetryUsed: false,
39
42
  emptyNudgeStreak: 0,
43
+ // Successful reactions (react tool): the input each was left against and
44
+ // whether it answered that input.
45
+ reactions: [],
40
46
  // Structured provider continuation signals honored this turn. Diagnostic
41
47
  // only: the loop has no iteration cap, so this count bounds nothing. A
42
48
  // text-only continuation runaway is bounded by continuationsSinceToolBatch
@@ -46,6 +52,39 @@ export function createNoToolTurnResolver({
46
52
  // continuation runaway (see PROVIDER_CONTINUATION_NO_TOOL_LIMIT).
47
53
  continuationsSinceToolBatch: 0,
48
54
  };
55
+ // The stable id (message-ids.mjs, kept across compaction) of the newest
56
+ // real user input; runtime reminders and notices are not reactable.
57
+ // A group-room member turn's prompt is a runtime message (not reactable)
58
+ // that carries the newest room-log user row it shows (`roomRow`).
59
+ const roomRowOf = (message) => message?.meta?.transcript?.roomRow || '';
60
+ const latestInput = () => messages.findLast((message) => isReactableMessage(message) || roomRowOf(message));
61
+ const latestInputId = () => messageIdOf(latestInput());
62
+ // A reaction exempts the input only while no newer input arrived since it
63
+ // landed (same latest input id) and it answered that input.
64
+ const reactionAnswersLatest = () => {
65
+ const id = latestInputId();
66
+ return state.reactions.some((reaction) => reaction.answered && reaction.inputId === id);
67
+ };
68
+ // What this batch's react calls hit, from the id the react tool recorded
69
+ // for each (never rebuilt from this transcript: a room reaction runs
70
+ // against the room's own log). Every record is consumed; a success that
71
+ // recorded none exempts nothing. A home-chat reaction answers the input
72
+ // only when it hit that very message; a room reaction, whose message is in
73
+ // another log, only when it hit the row this turn's prompt showed.
74
+ const batchReactions = (calls, results) => {
75
+ const input = latestInput();
76
+ const inputId = messageIdOf(input);
77
+ const hit = [];
78
+ for (const id of reactionCallIds(calls)) {
79
+ const target = takeReactionTarget(sessionId, id);
80
+ if (!target || !results.some((message) => isReactionSuccess(message, new Set([id])))) continue;
81
+ const answered = target.room
82
+ ? roomRowOf(input) === target.messageId
83
+ : inputId !== '' && target.messageId === inputId;
84
+ hit.push({ inputId, answered });
85
+ }
86
+ return hit;
87
+ };
49
88
  const trace = (iteration, kind, payload) => {
50
89
  try {
51
90
  appendAgentTrace({ sessionId, iteration, kind, payload, agent: sessionAgent || null });
@@ -101,7 +140,9 @@ export function createNoToolTurnResolver({
101
140
  // output, …) and a text-only terminal turn is the correct shape —
102
141
  // nudging them produces a contradictory user message that traps the
103
142
  // model in a tool-call-blocked vs contract-required oscillation.
104
- if (!hasContent && !HIDDEN_AGENT_NAMES.has(sessionAgent)) {
143
+ // A turn that left a reaction on the user's message may end with no
144
+ // text: the reaction is its answer (react tool), never an empty turn.
145
+ if (!hasContent && !HIDDEN_AGENT_NAMES.has(sessionAgent) && !reactionAnswersLatest()) {
105
146
  return nudgeEmptyTurn(
106
147
  response,
107
148
  { stopReason, isIncompleteStop: stopReason && INCOMPLETE_STOP_REASONS.has(stopReason) },
@@ -132,9 +173,16 @@ export function createNoToolTurnResolver({
132
173
  state.emptyNudgeStreak = 0;
133
174
  resetOutputLimitRecovery(state);
134
175
  },
135
- noteToolBatchCompleted() {
176
+ // `results` are the batch's tool result messages: only a react call that
177
+ // succeeded marks the turn as having reacted.
178
+ noteToolBatchCompleted(calls = [], results = []) {
136
179
  state.continuationsSinceToolBatch = 0;
137
180
  segments.clear();
181
+ state.reactions.push(...batchReactions(calls, results));
182
+ },
183
+ // The loop ended on a reaction that answered the latest user input.
184
+ get reactionCompleted() {
185
+ return reactionAnswersLatest();
138
186
  },
139
187
  get providerContinuations() {
140
188
  return state.providerContinuationCount;
@@ -96,6 +96,9 @@ export async function beginIteration(state) {
96
96
  opts.providerState = state.providerState;
97
97
  if (state.forcedFirstTool && state.toolCallsTotal === 0) {
98
98
  opts.toolChoice = 'required';
99
+ } else if (state.turnLimitNoticed) {
100
+ // A capped turn told to reply may only answer (turn-limits.mjs).
101
+ opts.toolChoice = 'none';
99
102
  } else {
100
103
  delete opts.toolChoice;
101
104
  }
@@ -7,7 +7,13 @@ import { runAbortable, throwIfAborted } from '../../../../../shared/abort-race.m
7
7
  import { evaluateExecPolicy } from '../../../tools/bash-policy-scan.mjs';
8
8
  import { beginSessionApprovalWait } from '../../manager/runtime-liveness.mjs';
9
9
  import { agentMcpToolPermission, writeAgentMcpToolPermission } from '../../../mcp/agent-connectors.mjs';
10
- import { actingBotId, actingContactBotId, humanGateDecision, recordHumanDecision } from '../../human-gate/gate.mjs';
10
+ import {
11
+ actingBotId,
12
+ actingContactBotId,
13
+ allowAllActionsEnabled,
14
+ humanGateDecision,
15
+ recordHumanDecision,
16
+ } from '../../human-gate/gate.mjs';
11
17
  import { applyEditedArgs } from '../../human-gate/requests.mjs';
12
18
 
13
19
  const isArgsObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
@@ -62,13 +68,16 @@ export const CONNECTOR_APPROVAL_REASON = 'This connector tool is set to ask befo
62
68
  * coding Lead, a task session, a workflow agent, a sub-agent) runs it unasked.
63
69
  * Run after the hooks, on the final call: a renamed call is judged under its
64
70
  * final name. A call the outbound gate owns (its card approved it, or a
65
- * remembered outbound grant answered it) is not asked a second time.
71
+ * remembered outbound grant answered it) is not asked a second time. With the
72
+ * "Allow all actions" developer option on, `needs_approval` is not asked
73
+ * either (a payment or permanent delete was asked by the outbound gate above).
66
74
  * @returns {Promise<{ denial: string } | null>} the denial, or null to run
67
75
  */
68
76
  export function applyConnectorApproval(call, { agentId, callerSessionId, executeOpts, toolApprovalHook }) {
69
77
  if (call.outboundApproved) return null;
70
78
  if (!actingBotId(callerSessionId)) return null;
71
79
  if (agentMcpToolPermission(agentId, call.name) !== 'needs_approval') return null;
80
+ if (allowAllActionsEnabled()) return null;
72
81
  // "Always allow" is offered only to the contact bot that is acting, for its
73
82
  // own policy entry for this one tool: never another bot or Mixy.
74
83
  const botId = actingContactBotId(callerSessionId);
@@ -9,6 +9,7 @@ import { processToolBatch } from '../tool-batch.mjs';
9
9
  import { CROSS_TURN_CAP, REPEAT_FAIL_LIMIT } from './loop-state.mjs';
10
10
  import { markStepEffort } from './step-auto-effort.mjs';
11
11
  import { resolveExecutionToolCalls } from './tool-call-resolution.mjs';
12
+ import { callsAllowed, settleTurnLimits, turnLimitsOf } from './turn-limits.mjs';
12
13
 
13
14
  function isSleepLikeToolCall(call) {
14
15
  const name = String(call?.name || call?.toolName || call?.function?.name || '').toLowerCase();
@@ -17,9 +18,13 @@ function isSleepLikeToolCall(call) {
17
18
 
18
19
  export async function runToolPhase(state, round, sent, onToolCall) {
19
20
  const { opts, messages, tools, sessionId, sessionRef, signal, response } = state;
20
- state.noToolTurn.noteToolCallTurn();
21
21
  const calls = response.toolCalls;
22
+ state.noToolTurn.noteToolCallTurn();
22
23
  round.resolveToolCallId.all(calls);
24
+ // A capped turn (turn-limits.mjs): calls past its cap are answered unrun,
25
+ // and a batch after it was told to reply ends it.
26
+ const allowed = callsAllowed(state);
27
+ const overrun = state.turnLimitNoticed === true;
23
28
  state.toolCallsTotal += calls.length;
24
29
  // Surface any mid-turn assistant text (preamble before a tool call) to the
25
30
  // UI. Providers that stream via onTextDelta already rendered it; providers
@@ -44,6 +49,7 @@ export async function runToolPhase(state, round, sent, onToolCall) {
44
49
  opts.onToolPhaseStarted?.();
45
50
  } catch {}
46
51
  const toolsT0 = Date.now();
52
+ const resultsFrom = messages.length;
47
53
  ({ dedupStubTotal: state.dedupStubTotal, editCount: state.editCount } = await processToolBatch({
48
54
  calls,
49
55
  messages,
@@ -67,10 +73,13 @@ export async function runToolPhase(state, round, sent, onToolCall) {
67
73
  pushToolResultMessage: state.pushToolResultMessage,
68
74
  throwIfAborted: state.throwIfAborted,
69
75
  repeatFailLimit: REPEAT_FAIL_LIMIT,
76
+ callsAllowed: allowed,
77
+ wrapUpAt: turnLimitsOf(state.opts)?.wrapUpAt,
70
78
  }));
71
79
  // A turn recovered from a stream cut that dropped an in-flight tool call
72
80
  // carries a runtime notice for the model, delivered after its results.
73
81
  if (response.recoveryNotice) messages.push(response.recoveryNotice);
82
+ settleTurnLimits(state, overrun);
74
83
  const toolsEndedAt = Date.now();
75
84
  // Starts the step judgment; the next request uses it if it has resolved by
76
85
  // then (send-phase.mjs applyStepEffort), else the one after.
@@ -91,6 +100,6 @@ export async function runToolPhase(state, round, sent, onToolCall) {
91
100
  });
92
101
  state.lastToolBatchEndedAt = toolsEndedAt;
93
102
  state.toolBatchJustCompleted = true;
94
- state.noToolTurn.noteToolBatchCompleted();
103
+ state.noToolTurn.noteToolBatchCompleted(calls, messages.slice(resultsFrom));
95
104
  state.lastToolBatchHadSleep = calls.some(isSleepLikeToolCall);
96
105
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * turn-limits.mjs — a turn's caps (sendOpts.turnLimits; a heartbeat turn's,
3
+ * manager/ask-turn-loop-options.mjs): `maxToolCalls` tool calls and
4
+ * `wrapUpAt` (epoch ms). A call past the cap, or one made after the wrap-up
5
+ * time, is answered without running. Once a cap is reached the model is told
6
+ * to reply, and every later request of the turn forbids tool use
7
+ * (toolChoice 'none'). A response that still calls tools after that ends the
8
+ * turn with no reply, its calls answered unrun. A capped turn runs no call
9
+ * eagerly (eager-dispatch.mjs), so none past the cap starts while streaming.
10
+ */
11
+
12
+ const NOTICE =
13
+ '[mixdog-runtime] This turn has reached its tool-call or time limit: no further tool call runs. Reply now with your final answer from what you already have.';
14
+ const REFUSAL =
15
+ '[turn-limit] This turn has reached its tool-call or time limit; this call was not run. Reply now without tools.';
16
+
17
+ /** `{ maxToolCalls, wrapUpAt }` of a capped turn (Infinity where unset), else null. */
18
+ export function turnLimitsOf(opts) {
19
+ const limits = opts?.turnLimits;
20
+ if (!limits || typeof limits !== 'object') return null;
21
+ return {
22
+ maxToolCalls: Number.isInteger(limits.maxToolCalls) && limits.maxToolCalls >= 0 ? limits.maxToolCalls : Infinity,
23
+ wrapUpAt: Number.isFinite(limits.wrapUpAt) ? limits.wrapUpAt : Infinity,
24
+ };
25
+ }
26
+
27
+ /** How many calls of the next batch may run, before the loop counts them. */
28
+ export function callsAllowed(state, now = Date.now()) {
29
+ const limits = turnLimitsOf(state.opts);
30
+ if (!limits) return Infinity;
31
+ if (state.turnLimitNoticed || now >= limits.wrapUpAt) return 0;
32
+ return Math.max(0, limits.maxToolCalls - state.toolCallsTotal);
33
+ }
34
+
35
+ /** The answer to a call the cap kept from running. */
36
+ export function turnLimitRefusal(call) {
37
+ return { role: 'tool', content: REFUSAL, toolCallId: call.id, toolKind: 'error' };
38
+ }
39
+
40
+ /**
41
+ * After a tool batch: `overrun` when the batch came after the turn was told
42
+ * to reply, which ends the turn (state.turnLimitEnded). Otherwise, once a cap
43
+ * is reached, the model is told once to reply.
44
+ */
45
+ export function settleTurnLimits(state, overrun, now = Date.now()) {
46
+ if (overrun) {
47
+ state.turnLimitEnded = true;
48
+ return;
49
+ }
50
+ const limits = turnLimitsOf(state.opts);
51
+ if (!limits || state.turnLimitNoticed) return;
52
+ if (state.toolCallsTotal < limits.maxToolCalls && now < limits.wrapUpAt) return;
53
+ state.turnLimitNoticed = true;
54
+ state.messages.push({ role: 'user', content: NOTICE, meta: { source: 'turn-limit' } });
55
+ }
56
+
57
+ /** The turn's end after an overrun: no reply. */
58
+ export function endedWithoutReply(response) {
59
+ return { ...response, content: '', toolCalls: [] };
60
+ }
@@ -3,6 +3,7 @@
3
3
  // its empty-final forensic record), the usage/cost ledger row, and the
4
4
  // opaque provider continuation state.
5
5
  import { cloneProviderReplay } from '../../providers/lib/provider-replay.mjs';
6
+ import { turnReminderField } from '../loop/assistant-commit.mjs';
6
7
  import { logLlmCall } from '../../usage-log.mjs';
7
8
  import { runAbortable } from '../../../../shared/abort-race.mjs';
8
9
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
@@ -31,6 +32,7 @@ export function appendAssistantTurnMessage({ session, sessionId, result, transcr
31
32
  ...(result.providerMetadata && typeof result.providerMetadata === 'object'
32
33
  ? { providerMetadata: result.providerMetadata }
33
34
  : {}),
35
+ ...turnReminderField(result),
34
36
  // Keep terminal provider evidence for non-empty turns too. A safety
35
37
  // classifier can emit narration and then refuse; omitting this
36
38
  // metadata made that shape indistinguishable from an ordinary
@@ -9,8 +9,21 @@ import { updateSessionStage, markSessionStreamDelta } from './runtime-liveness.m
9
9
  import { codexWireSendOpts } from './session-id.mjs';
10
10
  import { persistIterationMetrics } from './usage-metrics.mjs';
11
11
  import { isAgentModeSession } from '../agent-mode.mjs';
12
+ import { HEARTBEAT_TURN_LIMITS, isHeartbeatSession } from '../../../../shared/routine-trigger.mjs';
12
13
 
13
14
  const optionalFn = (value) => (typeof value === 'function' ? value : undefined);
15
+
16
+ /** Mixy's heartbeat turn is capped (loop/turn-limits.mjs): its tool calls, and
17
+ * the time after which it must reply. Keyed by the in-process heartbeat mark. */
18
+ export function heartbeatTurnLimits(sessionId, now = Date.now()) {
19
+ if (!isHeartbeatSession(sessionId)) return {};
20
+ return {
21
+ turnLimits: {
22
+ maxToolCalls: HEARTBEAT_TURN_LIMITS.maxToolCalls,
23
+ wrapUpAt: now + HEARTBEAT_TURN_LIMITS.wrapUpMs,
24
+ },
25
+ };
26
+ }
14
27
  const relay = (fn, ...args) => {
15
28
  try {
16
29
  fn?.(...args);
@@ -201,6 +214,7 @@ export function buildAgentLoopOptions({
201
214
  relay(askOpts?.onSkillsApplied, names, detail);
202
215
  },
203
216
  notifyFn: optionalFn(askOpts?.notifyFn),
217
+ ...heartbeatTurnLimits(sessionId),
204
218
  ...buildRequestSendOptions({
205
219
  sessionId,
206
220
  session,
@@ -224,7 +224,8 @@ export function publishAskTurn({ sessionId, opened, terminalResultPreview, askOp
224
224
  // Tag empty-synthesis BEFORE markSessionDone so the watchdog
225
225
  // (which inspects entry.emptyFinal first) classifies the
226
226
  // terminal state correctly even if it ticks during unwind.
227
- const isEmptyFinal = !terminalResultPreview.content && !terminalResultPreview.reasoningContent;
227
+ const isEmptyFinal =
228
+ !terminalResultPreview.content && !terminalResultPreview.reasoningContent && !terminalResultPreview.reactionCompleted;
228
229
  if (isEmptyFinal) {
229
230
  markSessionEmptyFinal(sessionId);
230
231
  }
@@ -10,6 +10,9 @@ import { isAgentOwner } from '../../agent-owner.mjs';
10
10
  import { getHiddenAgent } from '../../internal-agents.mjs';
11
11
  import { workflowDisallowsAgentTool } from '../../runtime-core/workflow.mjs';
12
12
  import { isAgentModeSession } from '../agent-mode.mjs';
13
+ import { agentIdOfHomeSession } from '../agent-home.mjs';
14
+ import { roomMemberOfSession } from '../agent-room.mjs';
15
+ import { REACTION_TOOL_NAME } from '../../runtime-core/reaction-tool-defs.mjs';
13
16
  import { finalizeSessionToolList, resolveSessionTools, permissionFromToolSpec } from './tool-resolution.mjs';
14
17
  import { unusedModelEditToolName } from '../../../../shared/edit-tool-dialect.mjs';
15
18
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
@@ -24,6 +27,14 @@ export function delegationDisabled(source, ownerIsAgent) {
24
27
  );
25
28
  }
26
29
 
30
+ // The react tool belongs to a bot's own chat with the user: its home chat
31
+ // (`sess_home_<id>`) or its seat in a group room (`sess_rmem_…`), decided by the
32
+ // session's id, never a coding session or a delegated worker.
33
+ export function reactionDisabled(source, ownerIsAgent) {
34
+ const id = String(source?.id || '');
35
+ return ownerIsAgent || !(agentIdOfHomeSession(id) || roomMemberOfSession(id));
36
+ }
37
+
27
38
  // Exactly two schema surfaces exist: Lead and Agent. Every Agent role gets
28
39
  // the full Lead-capable base bundle; the recursive `agent` control tool is
29
40
  // removed later at the owner boundary. Role permission remains prompt/
@@ -79,6 +90,7 @@ export function resolveSessionToolSurface(opts, { profile, toolPreset, modelName
79
90
  const sessionDeny = [
80
91
  ...(Array.isArray(opts.disallowedTools) ? opts.disallowedTools : []),
81
92
  ...(delegationDisabled(opts, ownerIsAgent) ? ['agent'] : []),
93
+ ...(reactionDisabled(opts, ownerIsAgent) ? [REACTION_TOOL_NAME] : []),
82
94
  ];
83
95
  // Role permission is prompt/diagnostic metadata only. Resolve and persist
84
96
  // it without shaping the provider-visible Agent schema.
@@ -144,6 +156,7 @@ export function _prepareResumeTools(session, preset) {
144
156
  disallowedTools: [
145
157
  ...(Array.isArray(session.disallowedTools) ? session.disallowedTools : []),
146
158
  ...(delegationDisabled(session, ownerIsAgent) ? ['agent'] : []),
159
+ ...(reactionDisabled(session, ownerIsAgent) ? [REACTION_TOOL_NAME] : []),
147
160
  ],
148
161
  ownerIsAgent,
149
162
  }),
@@ -26,7 +26,7 @@ export function messageIdOf(message) {
26
26
  }
27
27
 
28
28
  /** Whether `meta` can hold a transcript id (absent or a plain object). */
29
- export function canCarryMessageId(message) {
29
+ function canCarryMessageId(message) {
30
30
  if (!message || typeof message !== 'object') return false;
31
31
  const meta = message.meta;
32
32
  return meta === undefined || meta === null || (typeof meta === 'object' && !Array.isArray(meta));
@@ -101,12 +101,14 @@ export function stampMessageIds(messages, { sessionId = '' } = {}) {
101
101
 
102
102
  /** JSON.stringify replacer that leaves stable ids out: hashes and archives
103
103
  * the compactor derives from message JSON describe the conversation, not
104
- * its display bookkeeping (a meta that only held the id is left out too). */
104
+ * its display bookkeeping (a meta that only held the id is left out too).
105
+ * Reactions on a message (message-reactions.mjs) are display bookkeeping of
106
+ * the same kind, written in place mid-turn, and are left out with the id. */
105
107
  export function withoutMessageIdsReplacer(key, value) {
106
108
  if (key !== 'meta' || !value || typeof value !== 'object' || typeof value.transcript?.messageId !== 'string') {
107
109
  return value;
108
110
  }
109
- const { messageId: _id, ...transcript } = value.transcript;
111
+ const { messageId: _id, reactions: _reactions, ...transcript } = value.transcript;
110
112
  const meta = { ...value };
111
113
  if (Object.keys(transcript).length) meta.transcript = transcript;
112
114
  else delete meta.transcript;