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
@@ -4,7 +4,15 @@ import { createAgentTaskSessions } from '../session-runtime/agent-task-sessions.
4
4
  import { assertWorktreeMutationAllowed } from '../session-runtime/services/worktree-access.mjs';
5
5
  import { createBotStatus, scanCatalog, scopedBot, sessionBotOf } from '../session-runtime/bot-status.mjs';
6
6
  import { handoffContent } from '../session-runtime/handoff-attachments.mjs';
7
- import { BOT_HOP_LIMIT_ERROR, MAX_BOT_HOPS } from '../runtime/shared/bot-hops.mjs';
7
+ import { checkEffort, readModelCatalog } from '../session-runtime/task-session-route.mjs';
8
+ import {
9
+ BOT_HOP_LIMIT_ERROR,
10
+ MAX_BOT_HOPS,
11
+ assertBotChainTarget,
12
+ botChainOf,
13
+ rememberSubmissionBotChain,
14
+ } from '../runtime/shared/bot-hops.mjs';
15
+ import { agentIdOfHomeSession } from '../runtime/agent/orchestrator/session/agent-home.mjs';
8
16
  import { rebindDelegation } from '../runtime/agent/orchestrator/session/human-gate/delegation.mjs';
9
17
  import { isAgentOnlySession } from '../runtime/agent/orchestrator/session/store-summary-visibility.mjs';
10
18
  import { readSessionSidebarState } from '../runtime/shared/session-sidebar-host.mjs';
@@ -19,7 +27,13 @@ const TARGET_ACTIONS = new Set(['status', 'read', 'send', 'cancel', 'delete']);
19
27
  const MUTATING_ACTIONS = new Set(['send', 'cancel', 'delete']);
20
28
  // The workspace methods `worktree` reaches on another session (all on the
21
29
  // session protocol's read/configure allowlists).
22
- const WORKTREE_METHODS = new Set(['getBranchMenu', 'requestWorktree', 'selectBranch', 'mergeWorkspace', 'removeWorktree']);
30
+ const WORKTREE_METHODS = new Set([
31
+ 'getBranchMenu',
32
+ 'requestWorktree',
33
+ 'selectBranch',
34
+ 'mergeWorkspace',
35
+ 'removeWorktree',
36
+ ]);
23
37
  // Why a send from a conversation that is no bot's reports nothing back.
24
38
  const NOT_BOT_REASON = 'only a bot conversation gets session results reported back; check that session for the result';
25
39
 
@@ -120,22 +134,25 @@ export function createSessionControl({
120
134
  }
121
135
  }
122
136
  // Scoped like bots (`callerScope`, bot-status.mjs): the human's sessions
123
- // list everything, a bot (or its unattended run) only its own sessions,
124
- // anything else the plain rows without previews. Sub-agent sessions (a
137
+ // list everything with previews, a bot (or its unattended run) lists the
138
+ // same rows (so it can find a session to send to) but only its own carry
139
+ // the bot, activity, preview and working state, anything else the plain
140
+ // rows without previews. Sub-agent sessions (a
125
141
  // parent finds its own through the agent tool) and archived ones (the
126
142
  // desktop's flag) only on request; the scan pages past the rows left
127
143
  // out, so a page is never short while more rows match.
128
144
  const own = scopedBot(context.callerScope);
129
145
  const archived = (await readSessionSidebarState())?.archived ?? {};
130
146
  const keep = (row) =>
131
- (!own || sessionBotOf(row) === own) &&
132
147
  (args.include_agents === true || !isAgentOnlySession(row)) &&
133
148
  (args.include_archived === true || !Object.hasOwn(archived, row.id));
134
149
  // Bounded: past 10000 rows scanned with fewer than `limit` matching, the
135
150
  // page ends (possibly empty) with a nextCursor to continue from.
136
151
  const scan = await scanCatalog(service, keep, { cursor: args.cursor || null, want: limit, maxRows: 10_000 });
137
152
  const sessions = scan.rows.map((row) => (Object.hasOwn(archived, row.id) ? { ...row, archived: true } : row));
138
- return sessionListOutput({ sessions, nextCursor: scan.nextCursor }, limit, { rich: own !== null });
153
+ return sessionListOutput({ sessions, nextCursor: scan.nextCursor }, limit, {
154
+ rich: own ? (row) => sessionBotOf(row) === own : own !== null,
155
+ });
139
156
  }
140
157
  if (action === 'send') {
141
158
  if (typeof args.prompt !== 'string' || !args.prompt.trim()) {
@@ -153,6 +170,15 @@ export function createSessionControl({
153
170
  // its member session is the turn that answers and reports.
154
171
  const room = roomIdOfSession(sessionId) ? await roomAddressee(sessionId, args) : null;
155
172
  if (room?.sessionId === caller) throw new Error('session: send must target another session');
173
+ // Work goes on to a bot (a room seat's, or a bot's home) only outside
174
+ // the caller's bot chain (`callerBotChain`, bot-chain.mjs), never back
175
+ // to the caller's own bot; the turn it starts carries that chain on.
176
+ const botChain = botChainOf(context.callerBotChain);
177
+ assertBotChainTarget(
178
+ room ? room.agentId : agentIdOfHomeSession(sessionId),
179
+ { chain: botChain, self: context.callerBotId || context.callerAgentId || '' },
180
+ 'session'
181
+ );
156
182
  // An agent conversation's send reports its turn back to the agent's
157
183
  // home: a task session it created by its stored origin, any other
158
184
  // session by an origin for this send only.
@@ -161,23 +187,58 @@ export function createSessionControl({
161
187
  const turnSessionId = room ? room.sessionId : sessionId;
162
188
  const from = { sessionId: caller, ...(context.callerAgentId ? { agentId: context.callerAgentId } : {}) };
163
189
  // The member's turn carries the send's hop (`transcriptMeta.hop`), so a
164
- // chain through the room counts on toward MAX_BOT_HOPS.
190
+ // chain through the room counts on toward MAX_BOT_HOPS; its bot chain is
191
+ // recorded under every submission id that turn may take.
165
192
  const submit = room
166
- ? async ({ id, onSettled, follow, transcriptMeta } = {}) => ({
167
- accepted:
168
- (await room.host.ask(sessionId, {
169
- agentId: room.agentId,
170
- prompt,
171
- from,
172
- ...(id ? { id } : {}),
173
- ...(transcriptMeta?.hop > 0 ? { hop: transcriptMeta.hop } : {}),
174
- ...(onSettled ? { onSettled } : {}),
175
- ...(follow ? { follow } : {}),
176
- })) === true,
177
- })
193
+ ? async ({ id, onSettled, follow, transcriptMeta } = {}) => {
194
+ rememberSubmissionBotChain(id, transcriptMeta?.botChain);
195
+ const followed = follow
196
+ ? (submissionId) => {
197
+ rememberSubmissionBotChain(submissionId, transcriptMeta?.botChain);
198
+ return follow(submissionId);
199
+ }
200
+ : null;
201
+ return {
202
+ accepted:
203
+ (await room.host.ask(sessionId, {
204
+ agentId: room.agentId,
205
+ prompt,
206
+ from,
207
+ ...(id ? { id } : {}),
208
+ ...(transcriptMeta?.hop > 0 ? { hop: transcriptMeta.hop } : {}),
209
+ ...(onSettled ? { onSettled } : {}),
210
+ ...(followed ? { follow: followed } : {}),
211
+ })) === true,
212
+ };
213
+ }
178
214
  : null;
179
215
  const addressed = room ? { to: room.agentId } : {};
180
216
  let origin = await agentTasks.reportOrigin(turnSessionId, context.callerAgentId, caller);
217
+ // `effort` is the menu's effort pick on the target: checked now against the
218
+ // levels of its model, applied just before the prompt is queued.
219
+ let applyEffort = async () => {};
220
+ if (args.effort !== undefined) {
221
+ if (room) throw new Error('session: effort applies to a session, not a group room');
222
+ // Any bot, attended or not (`callerBotId`), changes only the task
223
+ // sessions it created; reports go only to a bot conversation.
224
+ const botId = context.callerBotId || context.callerAgentId || '';
225
+ if (botId && (await agentTasks.reportOrigin(sessionId, botId, caller))?.target === 'session') {
226
+ throw new Error('session: a bot may set the effort only of the task sessions it created');
227
+ }
228
+ const target = (await service.readSession({ sessionId, transcriptItemLimit: 1 })).full;
229
+ if (!target) throw new Error(`session: ${sessionId} is unavailable`);
230
+ const level = checkEffort(args.effort, target, await readModelCatalog(service, caller));
231
+ // sessionOnly: the session's route changes, never the user's saved model settings.
232
+ applyEffort = async () => {
233
+ const applied = await service.configureSession(
234
+ { sessionId, action: 'setEffort', args: [level, { sessionOnly: true }] },
235
+ { revision: 1 }
236
+ );
237
+ if (applied?.value === false) {
238
+ throw new Error(`session: ${sessionId} is busy with another command; effort not changed`);
239
+ }
240
+ };
241
+ }
181
242
  // A send made in a bot-descended turn (`callerHop` > 0), or by a bot to
182
243
  // another session, extends the chain by one; a bot's own task session
183
244
  // only continues it. Refused past the limit, whatever the target.
@@ -189,13 +250,17 @@ export function createSessionControl({
189
250
  if (origin) {
190
251
  const hop = chained(origin.target === 'session' ? turnHop + 1 : turnHop);
191
252
  if (hop > 0) origin = { ...origin, hop };
253
+ if (botChain.length) origin = { ...origin, botChain };
192
254
  // The task session a bot created acts as the sending conversation does
193
255
  // from now on (attended chat or run); another's session keeps its own.
194
256
  if (origin.target !== 'session') await rebindDelegation(sessionId, caller);
257
+ await applyEffort();
195
258
  const sent = await agentTasks.send(turnSessionId, prompt, origin, submit);
196
259
  return { sessionId, ...addressed, ...sent, ...(hop > 0 ? { hop } : {}) };
197
260
  }
198
261
  const hop = turnHop > 0 ? chained(turnHop + 1) : 0;
262
+ await applyEffort();
263
+ const chainMeta = { ...(hop > 0 ? { hop } : {}), ...(botChain.length ? { botChain } : {}) };
199
264
  let accepted;
200
265
  if (submit) accepted = (await submit(hop > 0 ? { transcriptMeta: { hop } } : {})).accepted;
201
266
  else {
@@ -204,7 +269,7 @@ export function createSessionControl({
204
269
  const result = await service.submitSession({
205
270
  sessionId,
206
271
  prompt,
207
- options: { mode: 'prompt', transcriptMeta: { sender: 'lead', ...(hop > 0 ? { hop } : {}) } },
272
+ options: { mode: 'prompt', transcriptMeta: { sender: 'lead', ...chainMeta } },
208
273
  });
209
274
  accepted = result.accepted === true;
210
275
  }
@@ -256,6 +321,9 @@ export function createSessionControl({
256
321
  cwd: snapshot.cwd ?? null,
257
322
  provider: snapshot.provider ?? null,
258
323
  model: snapshot.model ?? null,
324
+ effort: snapshot.effort ?? null,
325
+ autoEffort: snapshot.autoEffort ?? null,
326
+ fast: snapshot.fast ?? null,
259
327
  busy: snapshot.busy === true,
260
328
  quotaResume: snapshot.quotaResume ?? null,
261
329
  };
@@ -1001,6 +1001,8 @@ async function main() {
1001
1001
  setAgentHomeLiveSurface({
1002
1002
  busy: (sessionId) => sessionService.liveSessionBusy(sessionId),
1003
1003
  notice: (sessionId, notice) => sessionService.pushLiveSessionNotice(sessionId, notice),
1004
+ // A bot's emoji reaction on a user message (message-reactions.mjs).
1005
+ react: (sessionId, messageId, reactions) => sessionService.setLiveMessageReactions(sessionId, messageId, reactions),
1004
1006
  // A task session's result (session create): the agent relays it in one
1005
1007
  // turn. Resolves false when the turn was refused, else `{ settled }`, the
1006
1008
  // turn's end status ('done' when it finished).
@@ -1027,6 +1029,12 @@ async function main() {
1027
1029
  (await sessionService.submitSession({ sessionId, prompt, options }))?.accepted === true,
1028
1030
  abort: (sessionId) => sessionService.abortSession({ sessionId }),
1029
1031
  });
1032
+ // Mixy's 30-minute heartbeat check (mixy-heartbeat.mjs), on an unref'd timer.
1033
+ import('../session-runtime/mixy-heartbeat.mjs')
1034
+ .then(({ startMixyHeartbeat }) =>
1035
+ startMixyHeartbeat({ getSessionService: () => sessionService, log: (line) => log(`heartbeat: ${line}`) })
1036
+ )
1037
+ .catch((error) => log(`heartbeat start failed: ${error?.message || error}`));
1030
1038
  // Results queued before a restart are posted now; results a finished run
1031
1039
  // could not queue (kept on its routine's record) are queued again.
1032
1040
  recoverScheduleResults().catch((error) => log(`routine results recovery failed: ${error?.message || error}`));
@@ -47,6 +47,8 @@ export const SESSION_READ_ACTIONS = Object.freeze([
47
47
  'getQuotaHistory',
48
48
  'getRecapSettings',
49
49
  'getSessionTitleSettings',
50
+ 'getHeartbeatSettings',
51
+ 'getReactionSettings',
50
52
  'getLeadAppearance',
51
53
  'getBotOrder',
52
54
  'getWebSearchRoute',
@@ -134,6 +136,7 @@ export const SESSION_CONFIGURE_ACTIONS = Object.freeze([
134
136
  'deleteMediaAssets',
135
137
  'deleteSchedule',
136
138
  'saveCustomProvider',
139
+ 'saveCustomProviderKey',
137
140
  'removeCustomProvider',
138
141
  'updateCustomProviderModel',
139
142
  'setModelPreferences',
@@ -238,6 +241,8 @@ export const SESSION_CONFIGURE_ACTIONS = Object.freeze([
238
241
  'setProgressHint',
239
242
  'setRecapEnabled',
240
243
  'setAiSessionTitles',
244
+ 'setHeartbeatEnabled',
245
+ 'setReactionsEnabled',
241
246
  'setLeadAppearance',
242
247
  'setBotOrder',
243
248
  'setRoute',
@@ -64,9 +64,10 @@ export function createAgentCancellation({
64
64
  if (typeof closeCanonical !== 'function') {
65
65
  throw new TypeError('session runtime must implement closeCanonicalSession');
66
66
  }
67
+ const wasRunning = descriptor.status === 'running';
67
68
  // The close clears the transcript: capture a running turn's transcript
68
69
  // first so the cancelled turn still hands off its partial output.
69
- if (descriptor.status === 'running') {
70
+ if (wasRunning) {
70
71
  try {
71
72
  const { messages } = await entry.runtime.readModelMessages(0);
72
73
  rememberCancelTranscript(id, messages);
@@ -77,7 +78,10 @@ export function createAgentCancellation({
77
78
  const closed = await closeCanonical.call(entry.runtime, reason, { tombstone: options.tombstone === true });
78
79
  if (closed !== true) throw new Error(`agent session ${id} could not be durably closed`);
79
80
  descriptor.closed = options.tombstone === true;
80
- descriptor.status = descriptor.closed ? 'closed' : 'cancelled';
81
+ // Only a close that stopped a running turn is a cancellation; closing a
82
+ // finished agent (cleanup after its handoff) keeps its last status.
83
+ if (descriptor.closed) descriptor.status = 'closed';
84
+ else if (wasRunning) descriptor.status = 'cancelled';
81
85
  descriptor.stage = descriptor.status;
82
86
  descriptor.updatedAt = Date.now();
83
87
  return true;
@@ -128,6 +128,12 @@ export function createSessionTurnCalls(ctx) {
128
128
  const accepted = await Promise.resolve(target.call(entry.runtime, intake.prompt, submissionOptions));
129
129
  if (accepted === true) {
130
130
  entry.reservedOnly = false;
131
+ if (isUserTurn(submissionOptions)) {
132
+ // The user is active: Mixy's due heartbeat check waits for quiet.
133
+ void import('../../../session-runtime/mixy-heartbeat.mjs')
134
+ .then(({ noteUserTurn }) => noteUserTurn())
135
+ .catch((error) => log(`heartbeat activity note failed: ${error?.message || error}`));
136
+ }
131
137
  if (isAgentHomeSessionId(id) && isUserTurn(submissionOptions)) {
132
138
  // The user spoke in this agent's home: its round trips with other
133
139
  // sessions count from zero again, so a capped thread wakes again.
@@ -274,6 +274,18 @@ export function createSessionService({
274
274
  return true;
275
275
  }
276
276
 
277
+ /** Show the reactions now on message `messageId` in the hosted runtime of
278
+ * `sessionId` and publish them to its views; false when no runtime hosts
279
+ * it or its live rows do not hold that message. */
280
+ function setLiveMessageReactions(sessionId, messageId, reactions) {
281
+ const owner = projection.sessionOwner(sessionId);
282
+ if (typeof owner?.runtime?.setMessageReactions !== 'function') return false;
283
+ if (!owner.runtime.setMessageReactions(messageId, reactions)) return false;
284
+ const step = projection.advance(owner);
285
+ if (step.changed) projection.publishStep(owner, step);
286
+ return true;
287
+ }
288
+
277
289
  function getStatus() {
278
290
  const busy = retention.liveBusyCount();
279
291
  let watched = 0;
@@ -322,6 +334,7 @@ export function createSessionService({
322
334
  liveSessionBusy,
323
335
  liveSessionAwaitingApproval,
324
336
  pushLiveSessionNotice,
337
+ setLiveMessageReactions,
325
338
  stop,
326
339
  startQuotaResume: () => quotaResume?.start(),
327
340
  runDueQuotaResumes: () => quotaResume?.tick() ?? Promise.resolve(),
@@ -13,20 +13,23 @@ const outputBytes = (value) => Buffer.byteLength(JSON.stringify(value), 'utf8');
13
13
 
14
14
  /** Keep catalog paging authoritative even when the output budget shortens a page.
15
15
  * `rich` adds the owning bot, last activity, latest line and working state
16
- * (never for a background caller: bot-status.mjs scopedBot). */
16
+ * (never for a background caller: bot-status.mjs scopedBot). `rich` may be a
17
+ * per-row predicate: a bot gets them only for its own sessions. */
17
18
  export function sessionListOutput(page, limit, { rich = true } = {}) {
18
19
  const raw = page.sessions;
19
20
  const sessions = raw.slice(0, limit).map((row) => {
21
+ // Decided first: a session that is not the caller's carries its real
22
+ // title only, never a preview standing in for it.
23
+ const own = typeof rich === 'function' ? rich(row) : rich;
24
+ const title = own ? row.title || row.preview : row.title;
20
25
  const base = {
21
26
  id: row.id,
22
- ...(row.title || row.preview
23
- ? { title: escapeHarnessTags(truncateToolText(row.title || row.preview, 160)) }
24
- : {}),
27
+ ...(title ? { title: escapeHarnessTags(truncateToolText(title, 160)) } : {}),
25
28
  ...(row.status ? { status: row.status } : {}),
26
29
  ...(row.cwd ? { cwd: row.cwd } : {}),
27
30
  ...(row.archived === true ? { archived: true } : {}),
28
31
  };
29
- if (!rich) return base;
32
+ if (!own) return base;
30
33
  // The bot it belongs to: its chat, room seat or the task's creator.
31
34
  const bot = sessionBotOf(row);
32
35
  const lastUsedAt = Number(row.lastUsedAt) || 0;
@@ -173,6 +173,7 @@ const PROVIDER_DISPLAY_NAMES = new Map([
173
173
  ['command-code-oauth', 'Command Code'],
174
174
  ['mimo-oauth', 'MiMo'],
175
175
  ['cursor-oauth', 'Cursor OAuth'],
176
+ ['devin-oauth', 'Devin'],
176
177
  ['antigravity-oauth', 'Antigravity OAuth'],
177
178
  ['openai', 'OpenAI API'],
178
179
  ['openai-api', 'OpenAI API'],
@@ -279,6 +280,7 @@ const PROVIDER_DISPLAY_RANKS = {
279
280
  'mimo-oauth': 34,
280
281
  'antigravity-oauth': 31,
281
282
  'cursor-oauth': 32,
283
+ 'devin-oauth': 32.5,
282
284
  'opencode-go-oauth': 34.5,
283
285
  openai: 40,
284
286
  'openai-api': 40,
@@ -51347,6 +51347,7 @@ var PROVIDER_CATALOGS = Object.freeze({
51347
51347
  rosterLimitsAuthoritative: true
51348
51348
  },
51349
51349
  "cursor-oauth": { relay: true },
51350
+ "devin-oauth": { relay: true },
51350
51351
  "cursor-api": { relay: true },
51351
51352
  // The models.dev listing id of the Go gateway: a relay catalog, not a
51352
51353
  // selectable provider (the model lookup needs it flagged as a relay).
@@ -59670,6 +59671,7 @@ var PROVIDER_DISPLAY_NAMES = /* @__PURE__ */ new Map([
59670
59671
  ["command-code-oauth", "Command Code"],
59671
59672
  ["mimo-oauth", "MiMo"],
59672
59673
  ["cursor-oauth", "Cursor OAuth"],
59674
+ ["devin-oauth", "Devin"],
59673
59675
  ["antigravity-oauth", "Antigravity OAuth"],
59674
59676
  ["openai", "OpenAI API"],
59675
59677
  ["openai-api", "OpenAI API"],
@@ -59754,6 +59756,7 @@ var PROVIDER_DISPLAY_RANKS = {
59754
59756
  "mimo-oauth": 34,
59755
59757
  "antigravity-oauth": 31,
59756
59758
  "cursor-oauth": 32,
59759
+ "devin-oauth": 32.5,
59757
59760
  "opencode-go-oauth": 34.5,
59758
59761
  openai: 40,
59759
59762
  "openai-api": 40,
@@ -62131,12 +62134,30 @@ function markUserDataInitialized(dataDir2) {
62131
62134
  }
62132
62135
  var ensuredDataDirs = /* @__PURE__ */ new Set();
62133
62136
  function ensureUserDataInitialized(dataDir2) {
62134
- if (!dataDir2) return;
62137
+ if (!dataDir2 || skipBackups()) return;
62135
62138
  const key = normalizeDataDir(dataDir2);
62136
62139
  if (ensuredDataDirs.has(key)) return;
62137
- if (!hasUserDataInitMarker(dataDir2)) backupUserData(dataDir2, "initial-snapshot");
62140
+ if (!hasPublishedBackup(dataDir2)) {
62141
+ backupUserData(dataDir2, "initial-snapshot");
62142
+ if (!hasPublishedBackup(dataDir2)) return;
62143
+ }
62138
62144
  ensuredDataDirs.add(key);
62139
62145
  }
62146
+ function hasPublishedBackup(dataDir2) {
62147
+ const instanceId = readInitInstanceId(dataDir2);
62148
+ if (!instanceId) return false;
62149
+ const key = normalizeDataDir(dataDir2);
62150
+ const scopeDir = scopeDirFor(dataDir2);
62151
+ return listBackupDirsNewestFirst(scopeDir).some((name) => {
62152
+ try {
62153
+ const manifest = JSON.parse(readFileSync10(manifestPath(scopeDir, name), "utf8"));
62154
+ return typeof manifest?.dataDir === "string" && normalizeDataDir(manifest.dataDir) === key && manifest.instanceId === instanceId;
62155
+ } catch (error) {
62156
+ if (isAbsentBackupError(error)) return false;
62157
+ throw error;
62158
+ }
62159
+ });
62160
+ }
62140
62161
  function skipBackups() {
62141
62162
  return process.env.MIXDOG_SKIP_USER_DATA_BACKUP === "1" || process.env.MIXDOG_SKIP_USER_DATA_BACKUP === "true";
62142
62163
  }
@@ -62270,6 +62291,7 @@ var {
62270
62291
  setSecret: _setSecret,
62271
62292
  deleteSecret: _deleteSecret,
62272
62293
  hasSecret: _hasSecret,
62294
+ hasFallbackSecret: _hasFallbackSecret,
62273
62295
  setSecretAsync: _setSecretAsyncImpl,
62274
62296
  deleteSecretAsync: _deleteSecretAsyncImpl,
62275
62297
  keychainStatus: _keychainStatus,
@@ -35,10 +35,26 @@ export function createNoticeSurface({ getState, set, isDisposed, pushItem, repla
35
35
  // A routine result is a finished sentence, never a raw error to polish;
36
36
  // a group-room row is someone's words, kept verbatim (a pass has none).
37
37
  const value =
38
- options.routine || options.room || options.heldRequest ? String(text ?? '').trim() : polishNoticeText(text);
38
+ options.routine || options.room || options.heldRequest || options.assistant
39
+ ? String(text ?? '').trim()
40
+ : polishNoticeText(text);
39
41
  if (!value && !options.room) return null;
40
42
  const forceTranscript = options.transcript === true;
41
43
  if (!forceTranscript) return pushToast(value, tone, options.ttlMs, options);
44
+ // The agent's own message posted outside a turn (agent-home-delivery.mjs):
45
+ // its reply row, as the stored message restores.
46
+ if (options.assistant) {
47
+ const id = options.messageId || nextId();
48
+ pushItem({
49
+ kind: 'assistant',
50
+ id,
51
+ text: value,
52
+ streaming: false,
53
+ at: Date.now(),
54
+ ...(options.messageId ? { messageId: options.messageId } : {}),
55
+ });
56
+ return id;
57
+ }
42
58
  const userRow = options.room?.kind === 'user';
43
59
  // A room user row keeps its message id (the sender's submission id), as a
44
60
  // turn's own prompt row keeps its submission id.
@@ -138,6 +138,7 @@ export function createSessionIntegrationsApi(bag, { oauthFlows }) {
138
138
  (input) => runtime.saveCustomProvider(input),
139
139
  (result) => `custom provider saved: ${result.id}`
140
140
  ),
141
+ saveCustomProviderKey: (id, secret) => runtime.saveCustomProviderKey(id, secret),
141
142
  updateCustomProviderModel: (id, modelId, patch) => runtime.updateCustomProviderModel(id, modelId, patch),
142
143
  setModelPreferences: (provider, changes) => runtime.setModelPreferences(provider, changes),
143
144
  removeCustomProvider: async (id) => {
@@ -81,6 +81,14 @@ export function createSessionLifecycleApi(bag, { restoreTranscriptItems, oauthFl
81
81
  pushNotice,
82
82
  removeNotice,
83
83
  setProgressHint,
84
+ // A bot's emoji reactions on a user message (message-reactions.mjs): its
85
+ // live row shows them at once; false when the live rows do not hold it.
86
+ setMessageReactions: (messageId, reactions) => {
87
+ const row = bag
88
+ .getState()
89
+ .items.findLast((item) => item?.kind === 'user' && messageId && item.messageId === messageId);
90
+ return row ? bag.patchItem(row.id, { reactions: Array.isArray(reactions) ? reactions : [] }) : false;
91
+ },
84
92
  ...createSessionResetActions(bag),
85
93
  listSessions: (options) => {
86
94
  return runtime.listSessions(options);
@@ -34,8 +34,8 @@ export function createSessionSettingsApi(bag) {
34
34
  // refused: another command's lock does not block a selection.
35
35
  setModel: async (m) => (await bag.setSessionRoute({ model: m })) !== false,
36
36
  setEffort: withCommandLock(
37
- async (value) => {
38
- await runtime.setEffort(value);
37
+ async (value, options) => {
38
+ await runtime.setEffort(value, options);
39
39
  set({ ...routeState() });
40
40
  return runtime.effort || 'auto';
41
41
  },
@@ -112,6 +112,18 @@ export function createSessionSettingsApi(bag) {
112
112
  setAiSessionTitles: withCommandLock(async (enabled) => {
113
113
  return await runtime.setAiSessionTitles?.(enabled);
114
114
  }),
115
+ getHeartbeatSettings: () => {
116
+ return runtime.getHeartbeatSettings?.() || { heartbeat: true };
117
+ },
118
+ setHeartbeatEnabled: withCommandLock(async (enabled) => {
119
+ return await runtime.setHeartbeatEnabled?.(enabled);
120
+ }),
121
+ getReactionSettings: () => {
122
+ return runtime.getReactionSettings?.() || { reactions: true };
123
+ },
124
+ setReactionsEnabled: withCommandLock(async (enabled) => {
125
+ return await runtime.setReactionsEnabled?.(enabled);
126
+ }),
115
127
  getLeadAppearance: () => {
116
128
  return runtime.getLeadAppearance?.() || { avatar: null };
117
129
  },
@@ -40,6 +40,9 @@ export function createTurnStream(deps) {
40
40
  // (`commentary` / `final_answer`), or null; the desktop row carries it.
41
41
  phase: null,
42
42
  thinkingText: '',
43
+ // Length of thinkingText when the current provider send began: a
44
+ // `reasoning` retraction drops only what that send added.
45
+ thinkingSendMark: 0,
43
46
  thinkingStartedAt: 0,
44
47
  thinkingSegmentStartedAt: 0,
45
48
  accumulatedThinkingMs: 0,
@@ -492,9 +495,20 @@ export function appendReasoningDelta(stream, chunk) {
492
495
  scheduleStreamFlush(stream);
493
496
  }
494
497
 
498
+ // A provider send (one model request of the turn) begins: the thinking so far
499
+ // belongs to earlier sends and survives a retraction of this one.
500
+ export function markThinkingSendStart(stream) {
501
+ stream.thinkingSendMark = stream.thinkingText.length;
502
+ }
503
+
495
504
  export function resetStreamedText(stream, { chars, reasoning } = {}) {
496
505
  const count = Math.max(0, Number(chars) || 0);
497
- if (reasoning) endThinkingForText(stream);
506
+ if (reasoning) {
507
+ endThinkingForText(stream);
508
+ // The discarded attempt's thinking must not prefix the next attempt's;
509
+ // earlier sends' thinking stays.
510
+ stream.thinkingText = stream.thinkingText.slice(0, stream.thinkingSendMark);
511
+ }
498
512
  if (!count) return reasoning === true;
499
513
  flushStreamBatch(stream);
500
514
  stream.assistantText = stream.assistantText.slice(0, Math.max(0, stream.assistantText.length - count));
@@ -529,6 +543,8 @@ export function resetStreamedText(stream, { chars, reasoning } = {}) {
529
543
  // tool phase (`spinnerMode`: 'task-wait' or 'tool-use').
530
544
  export function closeThinkingForToolBatch(stream, spinnerMode) {
531
545
  const { getState, set } = stream;
546
+ // The send that produced this batch is complete; its thinking is kept.
547
+ markThinkingSendStart(stream);
532
548
  if (stream.thinkingText && getState().thinking) {
533
549
  const thinkingLastEndedAt = closeThinkingSegment(stream);
534
550
  set({
@@ -23,6 +23,10 @@ import { isRejected429 } from '../../runtime/shared/error-presentation.mjs';
23
23
  import { QUOTA_KINDS, providerErrorKind } from '../../runtime/agent/orchestrator/providers/provider-error-kind.mjs';
24
24
  import { turnFailureRecord } from '../../runtime/agent/orchestrator/session/turn-failure.mjs';
25
25
  import { registerTaskWaitSteeringCheck } from '../../runtime/agent/orchestrator/session/task-wait-control.mjs';
26
+ import {
27
+ isReactionSuccess,
28
+ reactionCallIds,
29
+ } from '../../runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs';
26
30
  import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
27
31
  import { yieldToRenderer } from './render-timing.mjs';
28
32
  import { archiveItemsForCompaction, compactionArchivedRows } from './compaction-view.mjs';
@@ -35,6 +39,7 @@ import {
35
39
  appendTextDelta,
36
40
  appendAssistantText,
37
41
  appendReasoningDelta,
42
+ markThinkingSendStart,
38
43
  noteAssistantPhase,
39
44
  resetStreamedText,
40
45
  applyStageChange,
@@ -152,6 +157,9 @@ function beginTurn({ flags, getState, transcriptRouteMetadata }, options) {
152
157
  askResult: null,
153
158
  finishedNormally: false,
154
159
  transcriptCompacted: false,
160
+ // Ids of this turn's react calls; `reacted` is set once one succeeds.
161
+ reactionCallIds: new Set(),
162
+ reacted: false,
155
163
  };
156
164
  }
157
165
 
@@ -403,10 +411,15 @@ export function toolHandlers(ctx) {
403
411
  if (isCurrentTurn()) settleSegmentBeforeToolCall(ctx);
404
412
  },
405
413
  onToolCall: async (_iter, calls) => {
406
- if (isCurrentTurn()) await openToolCalls(ctx, calls);
414
+ if (!isCurrentTurn()) return;
415
+ for (const id of reactionCallIds(calls)) ctx.turn.reactionCallIds.add(id);
416
+ await openToolCalls(ctx, calls);
407
417
  },
408
418
  onToolResult: (message) => {
409
419
  if (!isCurrentTurn()) return;
420
+ // A reaction that landed on the user's message is this turn's output
421
+ // even with no reply text; a rejected or failed react call is not.
422
+ if (isReactionSuccess(message, ctx.turn.reactionCallIds)) ctx.turn.reacted = true;
410
423
  try {
411
424
  options.onToolResult?.(message);
412
425
  } catch {}
@@ -440,7 +453,9 @@ export function streamHandlers(ctx) {
440
453
  const { isCurrentTurn, stream } = ctx;
441
454
  return {
442
455
  onProviderSendStarted: () => {
443
- if (isCurrentTurn()) markLiveCallRequestStart(stream);
456
+ if (!isCurrentTurn()) return;
457
+ markThinkingSendStart(stream);
458
+ markLiveCallRequestStart(stream);
444
459
  },
445
460
  onStageChange: async (stage, detail = null) => {
446
461
  if (!isCurrentTurn()) return;
@@ -554,6 +569,9 @@ export function turnDoneItem({ turn, stream, cards, getState, nextId }) {
554
569
  outputTokens: finalOutputTokens(stream, getState().spinner?.outputTokens),
555
570
  thinkingElapsedMs: stream.thinkingStartedAt ? stream.accumulatedThinkingMs : 0,
556
571
  toolCount: cards.toolCards.length,
572
+ // Its answer may be a reaction alone: readers of the turn's final answer
573
+ // take it as done with nothing to say (session-final-answer.ts).
574
+ ...(turn.reacted ? { reacted: true } : {}),
557
575
  verb: turn.completionVerb,
558
576
  at: Date.now(),
559
577
  ...(turn.failureDetail ? { detail: turn.failureDetail } : {}),
@@ -577,9 +595,11 @@ function publishTurnClose(ctx, closingItems) {
577
595
  const assistantOutput = (stream.currentAssistantText || stream.assistantText || '').trim();
578
596
  // Suppress only true pending-resume no-ops: no transcript items added and
579
597
  // no model output; cancelled/error turns and any visible turn stay marked.
598
+ // A turn whose only output was a reaction ends with its turndone row too.
580
599
  const isNoOpTurn =
581
600
  turn.finishedNormally &&
582
601
  !turn.cancelled &&
602
+ !turn.reacted &&
583
603
  cards.toolCards.length === 0 &&
584
604
  !resultContent &&
585
605
  !assistantOutput &&