mixdog 1.3.0 → 1.3.1

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 (103) hide show
  1. package/NOTICE.md +52 -9
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -1
  4. package/src/defaults/skills/setup/references/actions.md +4 -1
  5. package/src/defaults/skills/setup-guide/SKILL.md +10 -7
  6. package/src/output-styles/common.md +13 -8
  7. package/src/rules/agent-mode/AGENT_MODE.md +1 -1
  8. package/src/rules/agent-mode/MIXY.md +11 -4
  9. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +72 -14
  10. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +7 -2
  12. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +7 -8
  13. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +3 -1
  14. package/src/runtime/agent/orchestrator/providers/lib/tool-call-wire-ids.mjs +65 -0
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +8 -2
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +9 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +3 -1
  18. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +14 -2
  19. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +17 -0
  20. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +4 -2
  21. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +37 -7
  23. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +5 -3
  24. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +4 -0
  25. package/src/runtime/agent/orchestrator/session/agent-room.mjs +17 -8
  26. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +32 -5
  27. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +9 -0
  28. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +108 -6
  29. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +8 -14
  30. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +46 -4
  31. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +18 -8
  32. package/src/runtime/agent/orchestrator/session/manager/provider-replay.mjs +50 -0
  33. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -20
  34. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +3 -0
  35. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +7 -1
  36. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +5 -2
  38. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +17 -1
  39. package/src/runtime/agent/orchestrator/session/routine-result.mjs +61 -9
  40. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +14 -9
  41. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +92 -15
  42. package/src/runtime/attachments/store.mjs +6 -0
  43. package/src/runtime/effort-judge/judge-client.mjs +1 -1
  44. package/src/runtime/effort-judge/model-manifest.json +7 -7
  45. package/src/runtime/shared/agent-kind.mjs +152 -0
  46. package/src/runtime/shared/agent-route-config.d.mts +1 -0
  47. package/src/runtime/shared/agent-route-config.mjs +22 -8
  48. package/src/runtime/shared/agent-store.mjs +132 -0
  49. package/src/runtime/shared/bot-split-migration.mjs +457 -0
  50. package/src/runtime/shared/config-patch.mjs +20 -1
  51. package/src/runtime/shared/connector-catalog.d.mts +1 -2
  52. package/src/runtime/shared/connector-catalog.mjs +2 -4
  53. package/src/runtime/shared/connector-triggers.mjs +2 -0
  54. package/src/runtime/shared/err-text.mjs +3 -1
  55. package/src/runtime/shared/error-presentation.mjs +91 -6
  56. package/src/runtime/shared/handoff-artifacts.d.mts +20 -0
  57. package/src/runtime/shared/handoff-artifacts.mjs +61 -0
  58. package/src/runtime/shared/llm/provider-model-identities.mjs +6 -1
  59. package/src/runtime/shared/llm/usage-accounting.mjs +7 -1
  60. package/src/runtime/shared/model-route-fallback.mjs +79 -7
  61. package/src/runtime/shared/plain-preview.mjs +22 -17
  62. package/src/runtime/shared/sensitive-path-rules.d.mts +15 -0
  63. package/src/runtime/shared/sensitive-path-rules.mjs +256 -0
  64. package/src/runtime/shared/usage-readout-totals.mjs +15 -13
  65. package/src/runtime/shared/user-data-guard.mjs +5 -2
  66. package/src/session-runtime/agent-home-delivery.mjs +423 -56
  67. package/src/session-runtime/agent-room-delivery.mjs +45 -4
  68. package/src/session-runtime/agent-task-sessions.mjs +299 -39
  69. package/src/session-runtime/boot/apis.mjs +13 -0
  70. package/src/session-runtime/boot/providers.mjs +14 -4
  71. package/src/session-runtime/config-lifecycle.mjs +43 -2
  72. package/src/session-runtime/handoff-attachments.mjs +593 -0
  73. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +27 -3
  74. package/src/session-runtime/model-route/route-persist.mjs +2 -2
  75. package/src/session-runtime/model-route/route-tuning.mjs +7 -1
  76. package/src/session-runtime/model-route/set-route.mjs +21 -1
  77. package/src/session-runtime/native-web-search.mjs +2 -2
  78. package/src/session-runtime/new-session-config.mjs +6 -1
  79. package/src/session-runtime/route-resolution.mjs +5 -11
  80. package/src/session-runtime/services/agent-tool/helpers.mjs +24 -5
  81. package/src/session-runtime/services/agent-tool/send-flow.mjs +11 -3
  82. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
  83. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +5 -8
  84. package/src/session-runtime/services/agent-tool/tool-def.mjs +7 -0
  85. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  86. package/src/session-runtime/setup-tool/extended-actions.mjs +19 -7
  87. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -0
  88. package/src/session-runtime/tool-policy-refresh.mjs +13 -2
  89. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +11 -0
  90. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +123 -32
  91. package/src/session-runtime/workflow-agents-api/agent-route.mjs +67 -7
  92. package/src/session-runtime/workflow-agents-api/onboarding.mjs +11 -4
  93. package/src/standalone/daemon-session-control.mjs +13 -5
  94. package/src/standalone/daemon.mjs +16 -4
  95. package/src/standalone/session-service/agent-tree/agent-turns.mjs +46 -3
  96. package/src/standalone/session-service/agent-tree.mjs +4 -3
  97. package/src/standalone/session-service/session-calls/session-turns.mjs +19 -0
  98. package/src/tui/dist/index.mjs +70 -9
  99. package/src/tui/session/notice-surface.mjs +3 -4
  100. package/src/tui/session/session-api/routes.mjs +14 -3
  101. package/src/tui/session/transcript-intake.mjs +2 -0
  102. package/src/tui/session/turn-live-call.mjs +11 -3
  103. package/src/tui/session/turn.mjs +13 -3
@@ -18,14 +18,22 @@
18
18
  * registers the live surface so a thread open in a window shows the notice at
19
19
  * once; a cold thread picks it up from its stored record.
20
20
  */
21
+ import { randomUUID } from 'node:crypto';
21
22
  import { readFile } from 'node:fs/promises';
22
23
  import { join } from 'node:path';
23
24
  import {
24
25
  MIXY_AGENT_ID,
25
26
  agentHomeSessionId,
26
27
  agentIdOfHomeSession,
28
+ isAgentHomeSessionId,
27
29
  } from '../runtime/agent/orchestrator/session/agent-home.mjs';
28
30
  import {
31
+ isRoomThreadSessionId,
32
+ roomIdOfSession,
33
+ roomMemberOfSession,
34
+ } from '../runtime/agent/orchestrator/session/agent-room.mjs';
35
+ import {
36
+ TASK_LOOP_CAP,
29
37
  agentTaskMessage,
30
38
  agentTaskWakePrompt,
31
39
  routineOmittedMessage,
@@ -36,6 +44,7 @@ import {
36
44
  } from '../runtime/agent/orchestrator/session/routine-result.mjs';
37
45
  import { parseJsonText, updateJsonAtomic } from '../runtime/shared/atomic-file.mjs';
38
46
  import { ROUTINE_LIMITS } from '../runtime/shared/routine-limits.mjs';
47
+ import { submissionIdsOf } from './agent-task-sessions.mjs';
39
48
  import {
40
49
  heldRequestMessage,
41
50
  heldRequestNotice,
@@ -49,9 +58,14 @@ function deliveredRow(item, messageId) {
49
58
  return { message, notice: heldRequestNotice(message) };
50
59
  }
51
60
  if (item.kind === 'agent-task') {
52
- // A turn's end (not its start or a wait for the user) wakes the agent.
53
- const message = agentTaskMessage({ ...item, messageId });
54
- return { message, notice: routineResultNotice(message), wake: item.roundTrip === true };
61
+ // A turn's end (not its start or a wait for the user) wakes the agent —
62
+ // except past the round-trip cap with another agent's own thread, where
63
+ // each wake could answer the other agent again without end.
64
+ const capped =
65
+ (isAgentHomeSessionId(item.taskSessionId) || isRoomThreadSessionId(item.taskSessionId)) &&
66
+ (item.round || 0) > TASK_LOOP_CAP;
67
+ const message = agentTaskMessage({ ...item, messageId, ...(capped ? { woken: false } : {}) });
68
+ return { message, notice: routineResultNotice(message), wake: item.roundTrip === true && !capped };
55
69
  }
56
70
  const message = routineResultMessage({ ...item, messageId });
57
71
  return { message, notice: routineResultNotice(message) };
@@ -64,9 +78,10 @@ export function scheduleOwnerAgentId(schedule) {
64
78
  }
65
79
 
66
80
  let liveSurface = null;
67
- /** `{ busy(sessionId), notice(sessionId, notice), wake(sessionId, prompt),
81
+ /** `{ busy(sessionId), notice(sessionId, notice), wake(sessionId, prompt, { id }),
68
82
  * submit(sessionId, prompt, options), abort(sessionId) }` from the session
69
- * service; `wake` starts one hidden turn, `submit` queues a routine turn
83
+ * service; `wake` starts one hidden turn with submission `id` (resolves
84
+ * false when it was refused), `submit` queues a routine turn
70
85
  * (resolves whether it was accepted), `abort` stops the running turn. */
71
86
  export function setAgentHomeLiveSurface(surface) {
72
87
  liveSurface = surface || null;
@@ -80,19 +95,72 @@ export function agentHomeLiveSurface() {
80
95
  // Run ids already posted, kept per home so a replayed run never posts twice,
81
96
  // even into a thread that was reset ("대화 새로 시작") since.
82
97
  const DELIVERED_LEDGER = 500;
98
+ // A target's round-trip count left idle this long starts over.
99
+ export const ROUND_TTL_MS = 30 * 24 * 60 * 60 * 1000;
100
+ // Per home: round-trip targets counted (least recently counted drop first),
101
+ // reporting turns watched at once, and posted results owed a wake.
102
+ export const QUEUE_CAPS = Object.freeze({ roundTargets: 100, watches: 200, wakeRunIds: 100 });
103
+
104
+ /** `homeId`'s wake obligations in `wakes`: posted task results not in a batch
105
+ * yet (`owed`), those past the cap collapsed into one marker (`overflow`:
106
+ * `{ count, oldest, newest }` run ids) the next batch takes over, and
107
+ * batches (`{ id, runIds, overflow? }`) stored before their wake turn was
108
+ * submitted under that id. */
109
+ function wakeState(wakes, homeId) {
110
+ const raw = wakes[homeId];
111
+ if (Array.isArray(raw)) return { owed: raw, batches: [], overflow: null };
112
+ return {
113
+ owed: Array.isArray(raw?.owed) ? raw.owed : [],
114
+ batches: Array.isArray(raw?.batches)
115
+ ? raw.batches.filter((batch) => typeof batch?.id === 'string' && Array.isArray(batch.runIds))
116
+ : [],
117
+ overflow: raw?.overflow?.count > 0 ? raw.overflow : null,
118
+ };
119
+ }
120
+
121
+ const owesWake = (state) => state.owed.length > 0 || state.batches.length > 0 || Boolean(state.overflow);
122
+
123
+ function withWakeState(doc, homeId, { owed, batches, overflow }) {
124
+ const wakes = { ...doc.wakes };
125
+ if (owesWake({ owed, batches, overflow })) wakes[homeId] = { owed, batches, ...(overflow ? { overflow } : {}) };
126
+ else delete wakes[homeId];
127
+ return { ...doc, wakes };
128
+ }
83
129
 
84
130
  /** The persisted queue at `filePath`: `{ homes: { [homeSessionId]: item[] },
85
131
  * omitted: { [homeSessionId]: { id, count } }, delivered: { [homeSessionId]:
86
132
  * runId[] } }`. A home keeps at most `limit` items: past it the oldest drop,
87
133
  * counted under `omitted` so the thread gets one "n omitted" notice. */
88
- export function createRoutineResultQueue(filePath, { limit = ROUTINE_LIMITS.homeQueueLimit } = {}) {
134
+ export function createRoutineResultQueue(
135
+ filePath,
136
+ { limit = ROUTINE_LIMITS.homeQueueLimit, now = Date.now, caps = QUEUE_CAPS } = {}
137
+ ) {
89
138
  const section = (doc, key) => (doc && typeof doc[key] === 'object' && doc[key]) || {};
139
+ /** `own`'s per-target counts (`{ n, at }`) used within ROUND_TTL_MS, the
140
+ * `caps.roundTargets` most recently counted. */
141
+ const freshRounds = (own) =>
142
+ Object.fromEntries(
143
+ Object.entries(own && typeof own === 'object' ? own : {})
144
+ .filter(([, count]) => count && typeof count === 'object' && now() - (Number(count.at) || 0) < ROUND_TTL_MS)
145
+ .sort(([, a], [, b]) => (Number(b.at) || 0) - (Number(a.at) || 0))
146
+ .slice(0, caps.roundTargets)
147
+ );
148
+ const tidyRounds = (rounds) =>
149
+ Object.fromEntries(
150
+ Object.entries(rounds || {})
151
+ .map(([homeId, own]) => [homeId, freshRounds(own)])
152
+ .filter(([, own]) => Object.keys(own).length)
153
+ );
90
154
  const parts = (doc) => ({
91
155
  homes: section(doc, 'homes'),
92
156
  omitted: section(doc, 'omitted'),
93
157
  delivered: section(doc, 'delivered'),
94
158
  // Agent-initiated round trips per task session (session create).
95
159
  rounds: section(doc, 'rounds'),
160
+ // Reporting turns not yet queued, by submission id (agent-task-sessions.mjs).
161
+ watches: section(doc, 'watches'),
162
+ // Posted task results whose wake turn was not taken yet, per home.
163
+ wakes: section(doc, 'wakes'),
96
164
  });
97
165
  async function read() {
98
166
  try {
@@ -102,6 +170,15 @@ export function createRoutineResultQueue(filePath, { limit = ROUTINE_LIMITS.home
102
170
  throw error;
103
171
  }
104
172
  }
173
+ /** Read-modify-write under the file lock (`mutator(doc)` returns the next
174
+ * doc, or nothing to keep the file); every write also retires stale and
175
+ * surplus round counts. */
176
+ function update(mutator) {
177
+ return updateJsonAtomic(filePath, (raw) => {
178
+ const next = mutator(parts(raw));
179
+ return next ? { ...next, rounds: tidyRounds(next.rounds) } : undefined;
180
+ });
181
+ }
105
182
  /** `doc` with `items` appended to `homeId`'s list, minus run ids it holds or posted. */
106
183
  function appended(doc, homeId, items, omittedCount = 0) {
107
184
  const list = doc.homes[homeId] || [];
@@ -120,8 +197,80 @@ export function createRoutineResultQueue(filePath, { limit = ROUTINE_LIMITS.home
120
197
  }
121
198
  return {
122
199
  async pendingHomes() {
123
- const { homes, omitted } = await read();
124
- return [...new Set([...Object.keys(homes).filter((id) => homes[id]?.length), ...Object.keys(omitted)])];
200
+ const { homes, omitted, wakes } = await read();
201
+ return [
202
+ ...new Set([
203
+ ...Object.keys(homes).filter((id) => homes[id]?.length),
204
+ ...Object.keys(omitted),
205
+ ...Object.keys(wakes).filter((id) => owesWake(wakeState(wakes, id))),
206
+ ]),
207
+ ];
208
+ },
209
+ /** `homeId`'s wake obligations: `{ owed, batches, overflow }` (wakeState). */
210
+ async wakes(homeId) {
211
+ return wakeState((await read()).wakes, homeId);
212
+ },
213
+ /** Store the owed results (and overflow marker) of `homeId` as one new
214
+ * batch under a fresh wake id, before its wake is submitted; null when
215
+ * none are owed. */
216
+ async batchWake(homeId) {
217
+ let batch = null;
218
+ await update((doc) => {
219
+ batch = null;
220
+ const state = wakeState(doc.wakes, homeId);
221
+ if (!state.owed.length && !state.overflow) return undefined;
222
+ batch = {
223
+ id: `awake_${randomUUID()}`,
224
+ runIds: state.owed,
225
+ ...(state.overflow ? { overflow: state.overflow } : {}),
226
+ };
227
+ return withWakeState(doc, homeId, { owed: [], batches: [...state.batches, batch], overflow: null });
228
+ });
229
+ return batch;
230
+ },
231
+ /** The wake of batch `batchId` was taken (or is moot): it is owed no more. */
232
+ async woke(homeId, batchId) {
233
+ await update((doc) => {
234
+ const state = wakeState(doc.wakes, homeId);
235
+ const batches = state.batches.filter((batch) => batch.id !== batchId);
236
+ if (batches.length === state.batches.length) return undefined;
237
+ return withWakeState(doc, homeId, { ...state, batches });
238
+ });
239
+ },
240
+ /** Drop every wake obligation of `homeId` (its owner is gone). */
241
+ async dropWakes(homeId) {
242
+ await update((doc) =>
243
+ doc.wakes[homeId] ? withWakeState(doc, homeId, { owed: [], batches: [], overflow: null }) : undefined
244
+ );
245
+ },
246
+ /** Every home the rounds, wakes, posted ledger or watches name. */
247
+ async bookkeptHomes() {
248
+ const doc = await read();
249
+ const watched = Object.values(doc.watches).map((watch) => watch?.homeSessionId);
250
+ return [
251
+ ...new Set(
252
+ [...Object.keys(doc.rounds), ...Object.keys(doc.wakes), ...Object.keys(doc.delivered), ...watched].filter(
253
+ (id) => typeof id === 'string' && id
254
+ )
255
+ ),
256
+ ];
257
+ },
258
+ /** Retire the rounds, wakes, posted ledger and watches of the `gone`
259
+ * homes (and, as every write does, stale round counts). */
260
+ async prune(gone) {
261
+ await update((doc) => {
262
+ const kept = (part) => Object.fromEntries(Object.entries(part).filter(([homeId]) => !gone.includes(homeId)));
263
+ const next = {
264
+ ...doc,
265
+ rounds: tidyRounds(kept(doc.rounds)),
266
+ wakes: kept(doc.wakes),
267
+ delivered: kept(doc.delivered),
268
+ watches: Object.fromEntries(
269
+ Object.entries(doc.watches).filter(([, watch]) => !gone.includes(watch?.homeSessionId))
270
+ ),
271
+ };
272
+ return JSON.stringify(next) === JSON.stringify(doc) ? undefined : next;
273
+ });
125
274
  },
126
275
  async items(homeId) {
127
276
  return (await read()).homes[homeId] || [];
@@ -130,20 +279,72 @@ export function createRoutineResultQueue(filePath, { limit = ROUTINE_LIMITS.home
130
279
  async omitted(homeId) {
131
280
  return (await read()).omitted[homeId] || null;
132
281
  },
133
- async add(homeId, item) {
134
- await updateJsonAtomic(filePath, (raw) => {
135
- const doc = parts(raw);
136
- if (item.roundTrip !== true) return appended(doc, homeId, [item]) ?? undefined;
137
- // A task turn's end counts one round trip, once per run id.
138
- const round = (doc.rounds[item.taskSessionId] || 0) + 1;
139
- const next = appended(doc, homeId, [{ ...item, round }]);
140
- return next ? { ...next, rounds: { ...doc.rounds, [item.taskSessionId]: round } } : undefined;
282
+ /** Queue `item` for `homeId`; `unwatch` drops that watch in the same write,
283
+ * also when the item was queued or posted before. */
284
+ async add(homeId, item, { unwatch = '' } = {}) {
285
+ await update((doc) => {
286
+ let next = null;
287
+ if (item.roundTrip !== true) next = appended(doc, homeId, [item]);
288
+ else {
289
+ // A task turn's end counts one round trip, once per run id, per home
290
+ // and target: the user's next message in that home starts over
291
+ // (resetRounds), and so does a count idle past ROUND_TTL_MS.
292
+ const own = freshRounds(doc.rounds[homeId]);
293
+ const round = (own[item.taskSessionId]?.n || 0) + 1;
294
+ next = appended(doc, homeId, [{ ...item, round }]);
295
+ if (next) {
296
+ const counted = { ...own, [item.taskSessionId]: { n: round, at: now() } };
297
+ next = { ...next, rounds: { ...doc.rounds, [homeId]: counted } };
298
+ }
299
+ }
300
+ if (!unwatch || !doc.watches[unwatch]) return next ?? undefined;
301
+ const watches = { ...doc.watches };
302
+ delete watches[unwatch];
303
+ return { ...(next || doc), watches };
304
+ });
305
+ },
306
+ /** Keep `watch` ({ runId, agentId, homeSessionId, taskSessionId, title,
307
+ * target? }) until its report is queued, dropping the watches `drop` in
308
+ * the same write. Throws when its home already keeps `caps.watches`. */
309
+ async watch(watch, { drop = [] } = {}) {
310
+ await update((doc) => {
311
+ const watches = { ...doc.watches };
312
+ for (const runId of drop) delete watches[runId];
313
+ const own = Object.values(watches).filter(
314
+ (kept) => kept?.homeSessionId === watch.homeSessionId && kept.runId !== watch.runId
315
+ );
316
+ if (own.length >= caps.watches) {
317
+ throw new Error(`${watch.homeSessionId} already waits on ${own.length} session reports`);
318
+ }
319
+ return { ...doc, watches: { ...watches, [watch.runId]: watch } };
320
+ });
321
+ },
322
+ watchCap: caps.watches,
323
+ /** Every stored watch, oldest first. */
324
+ async watches() {
325
+ return Object.values((await read()).watches);
326
+ },
327
+ /** Drop the watch of `runId` (its turn was never taken). */
328
+ async unwatch(runId) {
329
+ await update((doc) => {
330
+ if (!doc.watches[runId]) return undefined;
331
+ const watches = { ...doc.watches };
332
+ delete watches[runId];
333
+ return { ...doc, watches };
334
+ });
335
+ },
336
+ /** The user spoke in `homeId`: its round trips count from zero again. */
337
+ async resetRounds(homeId) {
338
+ await update((doc) => {
339
+ if (!doc.rounds[homeId]) return undefined;
340
+ const rounds = { ...doc.rounds };
341
+ delete rounds[homeId];
342
+ return { ...doc, rounds };
141
343
  });
142
344
  },
143
345
  /** Hand every queued item (and omission) of `fromHome` to `toHome`. */
144
346
  async move(fromHome, toHome) {
145
- await updateJsonAtomic(filePath, (raw) => {
146
- const doc = parts(raw);
347
+ await update((doc) => {
147
348
  const items = doc.homes[fromHome] || [];
148
349
  const dropped = doc.omitted[fromHome]?.count || 0;
149
350
  const next = appended(doc, toHome, items, dropped) || doc;
@@ -154,10 +355,38 @@ export function createRoutineResultQueue(filePath, { limit = ROUTINE_LIMITS.home
154
355
  return { ...next, homes, omitted };
155
356
  });
156
357
  },
157
- /** Dequeue the posted `runIds` (and the posted omission notice `omitted`). */
158
- async remove(homeId, runIds, omitted = null) {
159
- await updateJsonAtomic(filePath, (raw) => {
160
- const doc = parts(raw);
358
+ /** Dequeue the posted `runIds` (and the posted omission notice `omitted`);
359
+ * those in `wake` stay owed a wake (`wakes`) in the same write, at most
360
+ * `caps.wakeRunIds` run ids per home: past it the oldest owed collapse
361
+ * into the overflow marker the next batch takes over (`overflowed`
362
+ * counts them). Stored batches never change. */
363
+ async remove(homeId, runIds, omitted = null, { wake = [] } = {}) {
364
+ let overflowed = 0;
365
+ await update((doc) => {
366
+ overflowed = 0;
367
+ const state = wakeState(doc.wakes, homeId);
368
+ const batched = state.batches.flatMap((batch) => batch.runIds);
369
+ const known = new Set([
370
+ ...state.owed,
371
+ ...batched,
372
+ ...[state.overflow?.oldest, state.overflow?.newest].filter(Boolean),
373
+ ]);
374
+ const added = wake.filter((runId) => !known.has(runId));
375
+ let wakes = doc.wakes;
376
+ if (added.length) {
377
+ const owed = [...state.owed, ...added];
378
+ const room = Math.max(0, caps.wakeRunIds - batched.length);
379
+ const excess = owed.slice(0, Math.max(0, owed.length - room));
380
+ overflowed = excess.length;
381
+ const overflow = excess.length
382
+ ? {
383
+ count: (state.overflow?.count || 0) + excess.length,
384
+ oldest: state.overflow?.oldest || excess[0],
385
+ newest: excess.at(-1),
386
+ }
387
+ : state.overflow;
388
+ ({ wakes } = withWakeState(doc, homeId, { ...state, owed: owed.slice(excess.length), overflow }));
389
+ }
161
390
  const homes = { ...doc.homes };
162
391
  const rest = (homes[homeId] || []).filter((row) => !runIds.includes(row.runId));
163
392
  if (rest.length) homes[homeId] = rest;
@@ -172,8 +401,9 @@ export function createRoutineResultQueue(filePath, { limit = ROUTINE_LIMITS.home
172
401
  if (left > 0) omittedNow[homeId] = { id: `${current.id}.${omitted.count}`, count: left };
173
402
  else delete omittedNow[homeId];
174
403
  }
175
- return { ...doc, homes, omitted: omittedNow, delivered };
404
+ return { ...doc, homes, omitted: omittedNow, delivered, wakes };
176
405
  });
406
+ return { overflowed };
177
407
  },
178
408
  };
179
409
  }
@@ -191,6 +421,12 @@ export function createAgentHomeDelivery({
191
421
  missingRetryMs = 60_000,
192
422
  // 'present' | 'deleted' | 'unknown' per agent id: a deleted owner's results go to Mixy.
193
423
  agentPresence = () => 'unknown',
424
+ // The same per room id. The daemon has no room store (rooms live in the
425
+ // desktop host), so by default no room counts as deleted.
426
+ roomPresence = () => 'unknown',
427
+ // True only on positive evidence that session `id` was deleted; an
428
+ // unreadable or unknown record is not deleted.
429
+ sessionDeleted = () => false,
194
430
  log = (line) => process.stderr.write(`mixdog routines: ${line}\n`),
195
431
  }) {
196
432
  const busy = (sessionId) => {
@@ -236,13 +472,15 @@ export function createAgentHomeDelivery({
236
472
  async function drainOnce(homeId) {
237
473
  const items = await queue.items(homeId);
238
474
  const omitted = await queue.omitted(homeId);
239
- if (!items.length && !omitted) return;
475
+ const owed = owesWake(await queue.wakes(homeId));
476
+ if (!items.length && !omitted && !owed) return;
240
477
  const agentId = agentIdOfHomeSession(homeId);
241
478
  if (agentId && agentId !== MIXY_AGENT_ID && agentPresence(agentId) === 'deleted') {
242
479
  // The owner is gone (its home archived): Mixy, the default owner, gets them.
243
480
  const mixyHome = agentHomeSessionId(MIXY_AGENT_ID);
244
481
  log(`${items.length} result(s) of the deleted agent ${agentId} go to ${mixyHome}`);
245
482
  await queue.move(homeId, mixyHome);
483
+ if (owed) await queue.dropWakes(homeId);
246
484
  void drain(mixyHome);
247
485
  return;
248
486
  }
@@ -265,11 +503,11 @@ export function createAgentHomeDelivery({
265
503
  session.messages.push(message);
266
504
  fresh.push(message);
267
505
  }
268
- const woken = [];
506
+ const toWake = [];
269
507
  for (const item of items) {
270
508
  const row = deliveredRow(item, messageIdOf(item.runId));
271
- // Every dequeued task result wakes once, posted in this pass or before.
272
- if (row.wake) woken.push(row.message);
509
+ // Every dequeued task result is owed one wake, posted in this pass or before.
510
+ if (row.wake) toWake.push(item.runId);
273
511
  if (posted.has(messageIdOf(item.runId))) continue;
274
512
  session.messages.push(row.message);
275
513
  fresh.push(row);
@@ -286,20 +524,48 @@ export function createAgentHomeDelivery({
286
524
  }
287
525
  }
288
526
  }
289
- await queue.remove(
527
+ // Dequeuing and owing the wake are one write: no restart between posting
528
+ // and waking loses the wake.
529
+ const { overflowed } = await queue.remove(
290
530
  homeId,
291
531
  items.map((item) => item.runId),
292
- omitted
532
+ omitted,
533
+ { wake: toWake }
293
534
  );
294
- // Dequeued task results wake the agent with ONE hidden turn to relay them;
295
- // a replayed run id is never queued again, so it wakes nobody.
296
- if (woken.length) {
297
- try {
298
- await live()?.wake?.(homeId, agentTaskWakePrompt(woken));
299
- } catch (error) {
300
- log(`waking ${homeId} failed: ${error?.message || error}`);
535
+ if (overflowed) log(`${homeId} is at its wake cap: ${overflowed} result(s) ride the next wake as a count`);
536
+ // Owed task results wake the agent with one hidden turn per batch. A batch
537
+ // is stored with its wake id BEFORE the wake is submitted and dropped once
538
+ // it is taken, so a restart in between re-checks that exact id; results
539
+ // owed later form a new batch. A replayed run id is never queued again.
540
+ const byId = new Map(session.messages.map((message) => [message?.meta?.transcript?.messageId, message]));
541
+ const taken = new Set(session.messages.flatMap((message) => submissionIdsOf(message)));
542
+ let batch = null;
543
+ let woken = [];
544
+ let more = 0;
545
+ for (;;) {
546
+ batch = (await queue.wakes(homeId)).batches[0] || (await queue.batchWake(homeId));
547
+ if (!batch) return;
548
+ const stale = taken.has(batch.id);
549
+ woken = stale ? [] : batch.runIds.map((runId) => byId.get(messageIdOf(runId))).filter(Boolean);
550
+ // Results past the wake cap ride this wake as a count of rows to read.
551
+ more = !stale && byId.has(messageIdOf(batch.overflow?.newest)) ? batch.overflow.count : 0;
552
+ if (woken.length || more) break;
553
+ // Taken before a restart, or its rows gone with a thread reset.
554
+ await queue.woke(homeId, batch.id);
555
+ }
556
+ const surface = live();
557
+ if (typeof surface?.wake !== 'function') return later(homeId, busyRetryMs);
558
+ try {
559
+ if ((await surface.wake(homeId, agentTaskWakePrompt(woken, { more }), { id: batch.id })) === false) {
560
+ throw new Error('the wake turn was refused');
301
561
  }
562
+ } catch (error) {
563
+ log(`waking ${homeId} failed, retrying: ${error?.message || error}`);
564
+ return later(homeId, missingRetryMs);
302
565
  }
566
+ await queue.woke(homeId, batch.id);
567
+ // Another batch left by an earlier process wakes once this turn is done.
568
+ if (owesWake(await queue.wakes(homeId))) later(homeId, busyRetryMs);
303
569
  }
304
570
 
305
571
  /** The single drainer of `homeId`; a call during a pass runs one more pass. */
@@ -365,19 +631,30 @@ export function createAgentHomeDelivery({
365
631
  /** Persist one report of a coding task session `agentId` created (session
366
632
  * create) for its home thread, then start draining it. `status` 'started'
367
633
  * and 'blocked' only post a line; a turn's end also wakes the agent. */
368
- async function enqueueTask(agentId, { runId, taskSessionId, title, status, text = '' }) {
634
+ async function enqueueTask(
635
+ agentId,
636
+ { runId, taskSessionId, title, status, text = '', target, watchId = '', files = [], omitted = 0 }
637
+ ) {
369
638
  const homeId = agentHomeSessionId(agentId) || agentHomeSessionId(MIXY_AGENT_ID);
370
639
  if (!runId || !taskSessionId) throw new Error('task report: runId and taskSessionId required');
371
- await queue.add(homeId, {
372
- runId: String(runId),
373
- kind: 'agent-task',
374
- taskSessionId,
375
- title,
376
- status,
377
- text: routineSummary(text),
378
- roundTrip: status !== 'started' && status !== 'blocked',
379
- at: Date.now(),
380
- });
640
+ await queue.add(
641
+ homeId,
642
+ {
643
+ runId: String(runId),
644
+ kind: 'agent-task',
645
+ taskSessionId,
646
+ title,
647
+ status,
648
+ text: routineSummary(text),
649
+ // Any session other than a task session the agent created.
650
+ ...(target === 'session' ? { target } : {}),
651
+ // The files the turn produced (handoff-attachments.mjs collectTurnArtifacts).
652
+ ...(Array.isArray(files) && files.length ? { files, ...(omitted > 0 ? { omitted } : {}) } : {}),
653
+ roundTrip: status !== 'started' && status !== 'blocked',
654
+ at: Date.now(),
655
+ },
656
+ { unwatch: watchId ? String(watchId) : '' }
657
+ );
381
658
  void drain(homeId);
382
659
  return homeId;
383
660
  }
@@ -398,27 +675,87 @@ export function createAgentHomeDelivery({
398
675
  }
399
676
  }
400
677
 
401
- /** Drain every queue left by an earlier process. */
678
+ /** A bookkept home proven deleted: a bot's home whose definition is gone,
679
+ * or a room thread whose room is. Never Mixy's home, never on a missing
680
+ * thread record, never on an unknown answer. */
681
+ function homeDeleted(homeId) {
682
+ const agentId = agentIdOfHomeSession(homeId);
683
+ if (agentId) return agentId !== MIXY_AGENT_ID && agentPresence(agentId) === 'deleted';
684
+ const roomId = roomIdOfSession(homeId) || roomMemberOfSession(homeId)?.roomId || '';
685
+ return Boolean(roomId) && roomPresence(roomId) === 'deleted';
686
+ }
687
+
688
+ /** Retire the bookkeeping of deleted homes (and stale round counts). */
689
+ async function prune() {
690
+ await queue.prune((await queue.bookkeptHomes()).filter(homeDeleted));
691
+ }
692
+
693
+ /** Store `watch`; a home at its watch cap first gives up its oldest watches
694
+ * whose target sessions are proven deleted, and refuses the watch (so the
695
+ * send claims no report) when that frees too few. */
696
+ async function watchTask(watch) {
697
+ const own = (await queue.watches()).filter(
698
+ (kept) => kept?.homeSessionId === watch.homeSessionId && kept.runId !== watch.runId
699
+ );
700
+ const over = own.length + 1 - queue.watchCap;
701
+ const drop =
702
+ over > 0
703
+ ? own
704
+ .filter((kept) => sessionDeleted(kept.taskSessionId))
705
+ .slice(0, over)
706
+ .map((kept) => kept.runId)
707
+ : [];
708
+ if (drop.length) {
709
+ log(`${watch.homeSessionId} is at its watch cap: dropped ${drop.length} watch(es) of deleted sessions`);
710
+ }
711
+ await queue.watch(watch, { drop });
712
+ }
713
+
714
+ /** Drain every queue left by an earlier process, after retiring stale bookkeeping. */
402
715
  async function recover() {
716
+ try {
717
+ await prune();
718
+ } catch (error) {
719
+ log(`pruning the routine results failed: ${error?.message || error}`);
720
+ }
403
721
  const homes = await queue.pendingHomes();
404
722
  for (const homeId of homes) void drain(homeId);
405
723
  return homes;
406
724
  }
407
725
 
408
- return { enqueue, enqueueHeld, enqueueTask, announce, drain, recover };
726
+ return {
727
+ enqueue,
728
+ enqueueHeld,
729
+ enqueueTask,
730
+ announce,
731
+ drain,
732
+ recover,
733
+ watchTask,
734
+ unwatchTask: (runId) => queue.unwatch(runId),
735
+ taskWatches: () => queue.watches(),
736
+ resetRounds: (homeId) => queue.resetRounds(homeId),
737
+ };
409
738
  }
410
739
 
411
740
  let defaultDelivery = null;
412
741
  /** The process delivery over the session store and data dir; built on first use. */
413
742
  async function processDelivery() {
414
743
  if (!defaultDelivery) {
415
- const [{ loadSession, saveSessionAsync }, { isSessionTurnInFlight }, { getPluginData }, { createAgentEditorApi }] =
416
- await Promise.all([
417
- import('../runtime/agent/orchestrator/session/store.mjs'),
418
- import('../runtime/agent/orchestrator/session/manager/runtime-liveness.mjs'),
419
- import('../runtime/agent/orchestrator/config.mjs'),
420
- import('./workflow-agents-api/agent-editor.mjs'),
421
- ]);
744
+ const [
745
+ { loadSession, saveSessionAsync },
746
+ { isSessionTurnInFlight },
747
+ { getPluginData },
748
+ { createAgentEditorApi },
749
+ { probePath, PROBE_ABSENT },
750
+ { sessionPath },
751
+ ] = await Promise.all([
752
+ import('../runtime/agent/orchestrator/session/store.mjs'),
753
+ import('../runtime/agent/orchestrator/session/manager/runtime-liveness.mjs'),
754
+ import('../runtime/agent/orchestrator/config.mjs'),
755
+ import('./workflow-agents-api/agent-editor.mjs'),
756
+ import('../runtime/agent/orchestrator/session/store/fs-probe.mjs'),
757
+ import('../runtime/agent/orchestrator/session/store/session-paths.mjs'),
758
+ ]);
422
759
  // The editor's per-id definition probe: 'deleted' only when proven gone.
423
760
  const { agentDefinitionPresence } = createAgentEditorApi({ cfgMod: { getPluginData } });
424
761
  defaultDelivery = createAgentHomeDelivery({
@@ -427,6 +764,16 @@ async function processDelivery() {
427
764
  turnInFlight: isSessionTurnInFlight,
428
765
  queue: createRoutineResultQueue(join(getPluginData(), 'routine-results.json')),
429
766
  agentPresence: (agentId) => agentDefinitionPresence([agentId])[agentId],
767
+ // Deleting a session unlinks its record (session-delete.mjs) and leaves
768
+ // no tombstone: only an absent file with no in-memory copy proves it.
769
+ // An unreadable file probes 'unreadable' and stays.
770
+ sessionDeleted: (sessionId) => {
771
+ try {
772
+ return probePath(sessionPath(sessionId)).state === PROBE_ABSENT && !loadSession(sessionId);
773
+ } catch {
774
+ return false;
775
+ }
776
+ },
430
777
  });
431
778
  }
432
779
  return defaultDelivery;
@@ -453,6 +800,26 @@ export async function deliverAgentTask(agentId, report) {
453
800
  return (await processDelivery()).enqueueTask(agentId, report);
454
801
  }
455
802
 
803
+ /** Keep one reporting turn's watch until its report is queued. */
804
+ export async function watchAgentTask(watch) {
805
+ return (await processDelivery()).watchTask(watch);
806
+ }
807
+
808
+ /** Drop one reporting turn's watch (its turn was never taken). */
809
+ export async function unwatchAgentTask(runId) {
810
+ return (await processDelivery()).unwatchTask(runId);
811
+ }
812
+
813
+ /** A user's own message in `homeId`: its round trips with every target restart. */
814
+ export async function resetAgentTaskRounds(homeId) {
815
+ return (await processDelivery()).resetRounds(homeId);
816
+ }
817
+
818
+ /** The reporting turns whose report was not queued yet. */
819
+ export async function agentTaskWatches() {
820
+ return (await processDelivery()).taskWatches();
821
+ }
822
+
456
823
  /** Daemon start: post the results an earlier process left queued. */
457
824
  export async function recoverScheduleResults() {
458
825
  return (await processDelivery()).recover();