mixdog 0.9.134 → 0.9.136

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 (133) hide show
  1. package/package.json +3 -1
  2. package/scripts/tool-stress.mjs +8 -1
  3. package/src/agents/maintainer/agent.json +1 -1
  4. package/src/defaults/skills/setup/SKILL.md +8 -43
  5. package/src/headless-exec.mjs +26 -9
  6. package/src/headless-exec.test.mjs +55 -2
  7. package/src/help.mjs +0 -2
  8. package/src/rules/lead/01-general.md +0 -4
  9. package/src/rules/shared/01-tool.md +12 -4
  10. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
  11. package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
  12. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
  13. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
  14. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +5 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +15 -6
  18. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +25 -4
  19. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +30 -7
  21. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
  23. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +138 -19
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +80 -3
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
  33. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
  34. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +3 -19
  35. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +25 -0
  36. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  37. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +165 -17
  38. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +162 -0
  39. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +36 -8
  40. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
  41. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  42. package/src/runtime/channels/lib/config.mjs +14 -101
  43. package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
  44. package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
  45. package/src/runtime/channels/lib/scheduler.mjs +75 -18
  46. package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
  47. package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
  48. package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
  49. package/src/runtime/channels/lib/worker-main.mjs +22 -307
  50. package/src/runtime/media/renditions.mjs +68 -5
  51. package/src/runtime/media/renditions.test.mjs +45 -0
  52. package/src/runtime/media/store.mjs +1 -0
  53. package/src/runtime/memory/index.mjs +4 -3
  54. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
  55. package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
  56. package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
  57. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  58. package/src/runtime/shared/config.mjs +8 -49
  59. package/src/runtime/shared/schedule-time.mjs +25 -0
  60. package/src/runtime/shared/schedule-time.test.mjs +29 -0
  61. package/src/runtime/shared/schedules-db.mjs +73 -1
  62. package/src/runtime/shared/tool-card-model.mjs +1 -1
  63. package/src/runtime/shared/transcript-writer.mjs +10 -17
  64. package/src/runtime/shared/update-checker.mjs +7 -2
  65. package/src/runtime/shared/update-checker.test.mjs +33 -0
  66. package/src/session-runtime/channel-config-api.mjs +3 -20
  67. package/src/session-runtime/config-lifecycle.mjs +1 -42
  68. package/src/session-runtime/lifecycle-api.mjs +10 -21
  69. package/src/session-runtime/model-route-api.mjs +0 -2
  70. package/src/session-runtime/model-settings-persist.test.mjs +0 -1
  71. package/src/session-runtime/prewarm.mjs +11 -7
  72. package/src/session-runtime/prewarm.test.mjs +0 -1
  73. package/src/session-runtime/runtime-core.mjs +20 -186
  74. package/src/session-runtime/session-lifecycle.mjs +4 -17
  75. package/src/session-runtime/session-transcript.mjs +54 -0
  76. package/src/session-runtime/session-turn-api.mjs +4 -44
  77. package/src/session-runtime/settings-api.mjs +0 -45
  78. package/src/session-runtime/tool-catalog-schema.mjs +1 -1
  79. package/src/session-runtime/tool-policy-surface.test.mjs +4 -0
  80. package/src/standalone/channel-admin.mjs +8 -150
  81. package/src/standalone/channel-transport.mjs +13 -0
  82. package/src/standalone/daemon.mjs +19 -49
  83. package/src/standalone/seeds.mjs +18 -1
  84. package/src/standalone/session-protocol.mjs +0 -8
  85. package/src/standalone/session-service.mjs +1 -3
  86. package/src/tui/App.jsx +0 -5
  87. package/src/tui/app/app-view.jsx +1 -2
  88. package/src/tui/app/create-app-pickers.mjs +0 -20
  89. package/src/tui/app/doctor.mjs +2 -6
  90. package/src/tui/app/prompt-submit.mjs +2 -49
  91. package/src/tui/app/settings-picker.mjs +22 -96
  92. package/src/tui/app/slash-commands.mjs +0 -2
  93. package/src/tui/app/slash-dispatch.mjs +0 -17
  94. package/src/tui/app/use-prompt-handlers.mjs +4 -1
  95. package/src/tui/components/StatusLine.jsx +4 -4
  96. package/src/tui/dist/index.mjs +1003 -1461
  97. package/src/tui/session/context-state.mjs +0 -1
  98. package/src/tui/session/queue-helpers.mjs +1 -1
  99. package/src/tui/session/queue-helpers.test.mjs +10 -1
  100. package/src/tui/session/session-api-ext.mjs +0 -89
  101. package/src/tui/session/session-api.mjs +0 -2
  102. package/src/tui/session/tool-card-results.mjs +1 -1
  103. package/src/tui/session/tool-result-status.mjs +3 -3
  104. package/src/tui/session/tool-result-status.test.mjs +3 -3
  105. package/src/tui/session-local.mjs +0 -26
  106. package/src/workflows/default/WORKFLOW.md +13 -15
  107. package/src/workflows/solo/WORKFLOW.md +6 -7
  108. package/src/runtime/channels/lib/config-lock.mjs +0 -13
  109. package/src/runtime/channels/lib/drop-trace.mjs +0 -71
  110. package/src/runtime/channels/lib/format.mjs +0 -190
  111. package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
  112. package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
  113. package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
  114. package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -289
  116. package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
  117. package/src/runtime/channels/lib/session-control.mjs +0 -36
  118. package/src/runtime/channels/lib/session-discovery.mjs +0 -217
  119. package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
  120. package/src/runtime/channels/lib/telegram-format.mjs +0 -280
  121. package/src/runtime/channels/lib/tool-format.mjs +0 -125
  122. package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
  123. package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
  124. package/src/runtime/channels/providers/discord-access.mjs +0 -30
  125. package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
  126. package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
  127. package/src/runtime/channels/providers/discord.mjs +0 -730
  128. package/src/runtime/channels/providers/telegram.mjs +0 -598
  129. package/src/runtime/channels/providers/telegram.test.mjs +0 -75
  130. package/src/session-runtime/remote-control.mjs +0 -126
  131. package/src/session-runtime/remote-transcript.mjs +0 -134
  132. package/src/session-runtime/remote-transition-queue.mjs +0 -14
  133. package/src/tui/app/channel-pickers.mjs +0 -384
@@ -12,29 +12,17 @@ import { captureOriginalUserCwd, readLastSessionCwd } from "../../shared/user-cw
12
12
  import { ensurePrivateRuntimeRoot, resolveRuntimeRoot } from "../../shared/runtime-root.mjs";
13
13
  import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
14
14
  import { Scheduler } from "./scheduler.mjs";
15
- import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./status-snapshot.mjs";
15
+ import { startSnapshotWriter, stopSnapshotWriter } from "./status-snapshot.mjs";
16
16
  import { hasPending as dispatchHasPending } from "../../agent/orchestrator/dispatch-persist.mjs";
17
17
  import { setListener as setActivityBusListener } from "../../agent/orchestrator/activity-bus.mjs";
18
18
  import { stripSoftWarns } from "../../agent/orchestrator/tool-loop-guard.mjs";
19
19
  import { WebhookServer } from "./webhook.mjs";
20
20
  import { EventPipeline } from "./event-pipeline.mjs";
21
21
  import { startCliWorker } from "./cli-worker-host.mjs";
22
- import {
23
- OutputForwarder,
24
- discoverSessionBoundTranscript,
25
- findLatestTranscriptByMtime,
26
- sameResolvedPath
27
- } from "./output-forwarder.mjs";
28
- import { controlClaudeSession } from "./session-control.mjs";
29
22
  import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./state-file.mjs";
30
- import {
31
- buildModalRequestSpec,
32
- PendingInteractionStore
33
- } from "./interaction-workflows.mjs";
34
23
  import {
35
24
  ensureRuntimeDirs,
36
25
  makeInstanceId,
37
- getTurnEndPath,
38
26
  getStatusPath,
39
27
  getChannelOwnerPath,
40
28
  getActiveOwnerPid,
@@ -61,24 +49,13 @@ import {
61
49
  BENIGN_CRASH_STREAK_WINDOW_MS,
62
50
  } from "./crash-log.mjs";
63
51
  import { dropTrace, preview, _dtIdxFlush } from "./index-drop-trace.mjs";
64
- import { createVoiceTranscription } from "./voice-transcription.mjs";
65
52
  import { createParentBridge } from "./parent-bridge.mjs";
66
- import { createInboundRouting } from "./inbound-routing.mjs";
67
53
  import { createToolDispatch } from "./tool-dispatch.mjs";
68
54
  import { createOwnerHeartbeat } from "./owner-heartbeat.mjs";
69
- import { createTranscriptBinding } from "./transcript-binding.mjs";
70
55
  import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
71
56
  import { runWorkerIpc } from "./worker-ipc.mjs";
72
- import { createInteractionHandlers } from "./interaction-handlers.mjs";
73
- import { createInboundHandler } from "./inbound-handler.mjs";
74
57
  import { createOwnedRuntime } from "./owned-runtime.mjs";
75
58
  import { runWorkerBootstrap } from "./worker-bootstrap.mjs";
76
- const memoryClientModulePath = new URL("./memory-client.mjs", import.meta.url).href;
77
- const {
78
- appendEntry: memoryAppendEntry,
79
- ingestTranscript: memoryIngestTranscript,
80
- drainBuffer: memoryDrainBuffer,
81
- } = await import(memoryClientModulePath);
82
59
  // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
83
60
  // after an unhandled rejection whose async state was already corrupted
84
61
  // (observed: EPERM on active-instance.json rename retry), so it spun
@@ -170,113 +147,12 @@ function writeBridgeState(active) {
170
147
  function isChannelBridgeActive() {
171
148
  return channelBridgeActive;
172
149
  }
173
- let typingChannelId = null;
174
- const pendingSetup = new PendingInteractionStore();
175
- function startServerTyping(channelId) {
176
- if (typingChannelId && typingChannelId !== channelId) {
177
- provider.stopTyping(typingChannelId);
178
- }
179
- typingChannelId = channelId;
180
- provider.startTyping(channelId);
181
- }
182
- function stopServerTyping() {
183
- if (typingChannelId) {
184
- provider.stopTyping(typingChannelId);
185
- typingChannelId = null;
186
- }
187
- }
188
- const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
189
- const TURN_END_BASENAME = path.basename(TURN_END_FILE);
190
- const TURN_END_DIR = path.dirname(TURN_END_FILE);
191
- let turnEndWatcher = null;
192
150
  // Config hot-reload watcher (installed by start(); torn down by stop()).
193
151
  let _configWatcher = null;
194
152
  let _reloadDebounce = null;
195
- if (!_isWorkerMode) {
196
- removeFileIfExists(TURN_END_FILE);
197
- turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
198
- if (filename !== TURN_END_BASENAME) return;
199
- try {
200
- const stat = fs.statSync(TURN_END_FILE);
201
- if (stat.size > 0) {
202
- stopServerTyping();
203
- await forwarder.forwardFinalText();
204
- removeFileIfExists(TURN_END_FILE);
205
- }
206
- } catch {
207
- }
208
- });
209
- }
210
153
  const STATUS_FILE = getStatusPath(INSTANCE_ID);
211
154
  const statusState = new JsonStateFile(STATUS_FILE, {});
212
155
  statusState.ensure();
213
- const forwarder = new OutputForwarder({
214
- send: async (ch, text, opts) => {
215
- if (!channelBridgeActive) {
216
- throw new Error("send() called while channel bridge is inactive");
217
- }
218
- await provider.sendMessage(ch, text, opts);
219
- },
220
- formatOutgoing: (text) => provider.formatOutgoing ? provider.formatOutgoing(text) : text,
221
- recordAssistantTurn: async () => {
222
- },
223
- react: (ch, mid, emoji) => {
224
- if (!channelBridgeActive) return Promise.resolve();
225
- return provider.react(ch, mid, emoji);
226
- },
227
- removeReaction: (ch, mid, emoji) => {
228
- if (!channelBridgeActive) return Promise.resolve();
229
- return provider.removeReaction(ch, mid, emoji);
230
- },
231
- // Watchdog backstop: force the provider to tear down + rebuild a wedged
232
- // client so an over-budget send fails fast and the queue releases.
233
- resetProvider: async () => {
234
- if (typeof provider?._resetClient === "function") await provider._resetClient();
235
- }
236
- }, statusState);
237
- forwarder.setOnIdle(() => {
238
- stopServerTyping();
239
- void forwarder.forwardFinalText();
240
- });
241
- // Wire the forwarder ownership probe unconditionally. wireEventQueueHandlers()
242
- // also sets this, but that path only runs when the event pipeline starts
243
- // (webhook enabled or event rules present). Without an event pipeline the
244
- // forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
245
- // non-owner process forward transcript output (duplicate Discord sends).
246
- // The closure reads bridgeRuntimeConnected at call time as a fast-path AND;
247
- // bridgeRuntimeConnected alone can go stale (e.g. this process lost the seat
248
- // but has not yet observed it), so currentOwnerState().owned is re-read at
249
- // probe time as the source of truth for ownership.
250
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
251
- // ── Transcript binding cluster ──────────────────────────────────────────────
252
- // Extracted → lib/transcript-binding.mjs. Bound to live config/identity/owner
253
- // getters so file-level reference semantics (runtime reloads, ownership flips)
254
- // are preserved.
255
- const {
256
- sessionIdFromTranscriptPath,
257
- getPersistedTranscriptPath,
258
- pickUsableTranscriptPath,
259
- applyTranscriptBinding,
260
- cancelPendingTranscriptRearm,
261
- schedulePendingTranscriptRearm,
262
- rebindTranscriptContext,
263
- bindPersistedTranscriptIfAny,
264
- } = createTranscriptBinding({
265
- forwarder,
266
- statusState,
267
- readActiveInstance,
268
- refreshActiveInstance,
269
- instanceId: INSTANCE_ID,
270
- memoryIngestTranscript,
271
- memoryDrainBuffer,
272
- dropTrace,
273
- discoverSessionBoundTranscript,
274
- sameResolvedPath,
275
- getConfig: () => config,
276
- getChannelBridgeActive: () => channelBridgeActive,
277
- getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
278
- RUNTIME_ROOT,
279
- });
280
156
  const scheduler = new Scheduler(
281
157
  config.nonInteractive ?? [],
282
158
  config.interactive ?? [],
@@ -305,15 +181,6 @@ let bridgeRuntimeConnected = false;
305
181
  // in-flight start does not revive owner state after the stop already tore
306
182
  // the partial-start state down.
307
183
  const ACTIVE_OWNER_STALE_MS = 1e4;
308
- // Owner gating here is multi-process runtime coordination: only the active
309
- // bindingReady gates all send paths until the boot-time refreshBridgeOwnership
310
- // ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
311
- // emissions fired within the first ~few hundred ms after restart drop because
312
- // the Discord provider binding has not yet been established.
313
- let bindingReadyStatus = "pending";
314
- let _bindingReadyResolve;
315
- const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
316
- dropTrace("bindingReady.create", { status: bindingReadyStatus });
317
184
  // ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
318
185
  // Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
319
186
  // dedup; bound to live identity + active-instance primitives.
@@ -350,19 +217,13 @@ const {
350
217
  getChannelBridgeActive: () => channelBridgeActive,
351
218
  instanceId: INSTANCE_ID,
352
219
  TERMINAL_LEAD_PID,
353
- forwarder,
354
220
  sendNotifyToParent,
355
221
  scheduler,
356
222
  statusState,
357
223
  logOwnership,
358
224
  currentOwnerState,
359
- bindPersistedTranscriptIfAny,
360
- cancelPendingTranscriptRearm,
361
- schedulePendingTranscriptRearm,
362
- stopServerTyping,
363
225
  wireWebhookHandlers,
364
226
  wireEventQueueHandlers,
365
- memoryDrainBuffer,
366
227
  });
367
228
  function injectAndRecord(channelId, name, content, options) {
368
229
  // Strip soft-warn marker blocks (Tool-loop / Repeated-input / legacy
@@ -372,7 +233,7 @@ function injectAndRecord(channelId, name, content, options) {
372
233
  // intentionally prepended onto tool RESULTS upstream (tool-loop-guard.mjs
373
234
  // build*Warn) so the model
374
235
  // self-corrects, but agent roles commonly echo them and we don't want them
375
- // surfacing in Discord / Lead channel push.
236
+ // surfacing in the Lead channel push.
376
237
  if (typeof content === 'string') content = stripSoftWarns(content);
377
238
  const ts = new Date().toISOString();
378
239
  const now = new Date();
@@ -382,39 +243,10 @@ function injectAndRecord(channelId, name, content, options) {
382
243
  if (options?.instruction) meta.instruction = options.instruction;
383
244
  if (options?.type) meta.type = options.type;
384
245
  // `silent_to_agent` — lifecycle status pings (worker/iter/started echoes)
385
- // surface on Discord but should NOT land in Lead's context window. When
386
- // set, skip the parent-notify hop but keep the Discord-forward + event-log
387
- // record. The meta flag is also propagated downstream so consumers that
388
- // still see the notification (e.g. Lead itself if emission changes later)
389
- // can recognise and drop it. Default is false → legacy behaviour preserved.
390
- if (options?.silent_to_agent) meta.silent_to_agent = true;
391
- const silent = options?.silent_to_agent === true;
392
- if (!silent) {
393
- sendNotifyToParent("notifications/claude/channel", { content, meta });
394
- } else {
395
- forwardLifecycleToDiscord(channelId, content);
396
- }
397
- }
398
-
399
- // Best-effort direct Discord emission for silent-to-agent lifecycle pings.
400
- // Only used when the parent-notify hop is skipped, so the user still sees
401
- // the status on Discord even though Lead will never echo it through the
402
- // transcript-tail forwarder. Falls back to a no-op when no channel is
403
- // resolvable — lifecycle pings are non-critical.
404
- function forwardLifecycleToDiscord(channelId, content) {
405
- try {
406
- // Skip rather than guess: lifecycle callers pass the channelId they own;
407
- // falling back to statusState.channelId can route to a stale/unrelated
408
- // channel when the caller did not supply one intentionally.
409
- const target = channelId || null;
410
- dropTrace("send.lifecycle.entry", { channelId: target || "(none)", bindingReadyStatus, providerPresent: !!provider?.sendMessage, preview: preview(content) });
411
- if (!target || !provider?.sendMessage) return;
412
- void bindingReady.then(() =>
413
- provider.sendMessage(target, content)
414
- .then(() => dropTrace("send.lifecycle.ok", { channelId: target }))
415
- .catch((err) => dropTrace("send.lifecycle.err", { channelId: target, err: String(err) }))
416
- ).catch(() => {});
417
- } catch { /* best-effort */ }
246
+ // must NOT land in Lead's context window. The channel relay that used to
247
+ // surface them is retired, so silent pings are dropped entirely.
248
+ if (options?.silent_to_agent === true) return;
249
+ sendNotifyToParent("notifications/claude/channel", { content, meta });
418
250
  }
419
251
  scheduler.setInjectHandler((channelId, name, content, options) => {
420
252
  injectAndRecord(channelId, name, content, options);
@@ -423,24 +255,15 @@ scheduler.setInjectHandler((channelId, name, content, options) => {
423
255
  // actually holds the live bridge seat; otherwise the scheduler falls back to
424
256
  // the visible-session run so the fire is never lost.
425
257
  scheduler.setInjectReadyCheck(() => bridgeRuntimeConnected && currentOwnerState().owned);
426
- scheduler.setSendHandler(async (channelId, text) => {
427
- dropTrace("send.scheduler.entry", { channelId, preview: preview(text) });
428
- await bindingReady;
429
- dropTrace("send.scheduler.ready", { channelId });
430
- try {
431
- await provider.sendMessage(channelId, text);
432
- dropTrace("send.scheduler.ok", { channelId });
433
- } catch (err) {
434
- dropTrace("send.scheduler.err", { channelId, err: String(err) });
435
- throw err;
436
- }
258
+ scheduler.setSendHandler(async () => {
259
+ // Channel delivery is retired: schedule results surface in the app session
260
+ // (parent notify / Automations row) only.
437
261
  });
438
262
  function wireWebhookHandlers() {
439
263
  if (!webhookServer) return;
440
264
  webhookServer.setEventPipeline(eventPipeline);
441
- // Webhook fires run as sessions (schedules parity). Delivery mode:
442
- // 'app' (default) → the Automations session row IS the surface;
443
- // 'channel'/'both' → the run result also relays to the main channel.
265
+ // Webhook fires run as sessions (schedules parity); the Automations
266
+ // session row is the only surface (channel relay retired).
444
267
  webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context }) => {
445
268
  const { runWebhookSession } = await import("../../shared/webhook-session-run.mjs");
446
269
  const run = await runWebhookSession({
@@ -452,14 +275,6 @@ function wireWebhookHandlers() {
452
275
  delivery: delivery || null,
453
276
  prompt,
454
277
  });
455
- const mode = String(delivery || "app");
456
- if ((mode === "channel" || mode === "both") && run?.result && provider?.sendMessage) {
457
- const target = scheduler.resolveChannel("");
458
- if (target) {
459
- void bindingReady.then(() => provider.sendMessage(target, run.result))
460
- .catch((err) => dropTrace("send.webhook.err", { channelId: target, err: String(err) }));
461
- }
462
- }
463
278
  return run;
464
279
  });
465
280
  }
@@ -474,127 +289,37 @@ function wireEventQueueHandlers(eventQueue) {
474
289
  // bridgeRuntimeConnected is a fast-path AND; currentOwnerState().owned is
475
290
  // re-read at probe time so a stale-connected flag cannot mask a lost seat.
476
291
  eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
477
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
478
292
  }
479
- createInteractionHandlers({
480
- getProvider: () => provider,
481
- getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
482
- instanceId: INSTANCE_ID,
483
- getBridgeOwnershipSnapshot,
484
- refreshBridgeOwnershipSafe,
485
- pendingSetup,
486
- buildModalRequestSpec,
487
- loadProfileConfig,
488
- sendNotifyToParent,
489
- scheduler,
490
- controlClaudeSession,
491
- writeTextFile,
492
- TURN_END_FILE,
493
- });
494
- const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
495
- getConfig: () => config,
496
- dataDir: DATA_DIR,
497
- });
293
+ // Inbound messaging, provider interaction handlers, and channel voice-message
294
+ // transcription are DELETED with Discord/Telegram (user decision: PWA replaces
295
+ // channels). The headless provider emits no events, so nothing wires here.
498
296
  import { TOOL_DEFS } from '../tool-defs.mjs';
499
297
  // Tool dispatch in worker mode goes through the IPC `call` handler at the
500
298
  // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
501
299
  // orphan worker-level MCP Server: the parent (server.mjs) owns the single
502
300
  // connected transport and routes CallTool through the IPC `call` path.
503
- // ── Inbound routing / dedup / ownership helpers ─────────────────────────────
504
- // Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
505
- const {
506
- writeChannelOwner,
507
- shouldDropDuplicateInbound,
508
- resolveInboundRoute,
509
- } = createInboundRouting({
510
- getConfig: () => config,
511
- getInstanceId: () => INSTANCE_ID,
512
- getChannelOwnerPath,
513
- });
514
301
  // ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
515
- // handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
516
- // lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
517
- // functions plus mutable owner state (channelBridgeActive/bridgeRuntimeConnected)
518
- // and the forwarder; those are threaded as a lifecycle bag of lazy getters so
519
- // the module reads live file-level references at call time (original closure
520
- // semantics preserved). Used by the HTTP MCP CallTool path and the worker IPC
521
- // `call` handler at the bottom of this file.
302
+ // Runtime lifecycle dispatch only (activate_channel_bridge / reload_config):
303
+ // threaded as a lifecycle bag of lazy getters so the module reads live
304
+ // file-level references at call time. Used by the HTTP MCP CallTool path and
305
+ // the worker IPC `call` handler at the bottom of this file.
522
306
  const {
523
307
  handleToolCall,
524
308
  handleToolCallWithBridgeRetry,
525
309
  } = createToolDispatch({
526
- getForwarder: () => forwarder,
527
310
  isChannelsDegraded,
528
311
  lifecycle: {
529
- getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
530
312
  getChannelBridgeActive: () => channelBridgeActive,
531
313
  getOwned: () => getBridgeOwnershipSnapshot().owned,
532
314
  setChannelBridgeActive: (v) => { channelBridgeActive = v; },
533
315
  writeBridgeState,
534
- stopServerTyping,
535
316
  notifyRemoteAcquired,
536
317
  refreshBridgeOwnership,
537
- bindPersistedTranscriptIfAny,
538
- // Lead-pushed repoint: bind the exact transcript the lead just created
539
- // (auto-acquire / newSession / resume / clear) instead of waiting for the
540
- // next inbound parent-chain steal. Idempotent + best-effort: same binding
541
- // path as the inbound steal (rebindTranscriptContext -> applyTranscriptBinding).
542
- rebindCurrentTranscript: async (transcriptPath) => {
543
- const cleanPath = typeof transcriptPath === "string" ? transcriptPath.trim() : "";
544
- if (!cleanPath) return;
545
- const channelId = statusState.read().channelId || config.channelId;
546
- if (!channelId) return;
547
- // Fail-closed: a malformed / not-yet-on-disk path must NEVER fall through
548
- // to rebindTranscriptContext's discovery loop (which would mutate the
549
- // binding onto a different session). Only an existing regular file binds;
550
- // otherwise log + return so the current binding is left untouched.
551
- let exists = false;
552
- try { exists = fs.statSync(cleanPath).isFile(); } catch { exists = false; }
553
- if (!exists) {
554
- process.stderr.write(`mixdog: rebind_current_transcript: ignoring non-existent path ${cleanPath}\n`);
555
- return;
556
- }
557
- // Idempotent: already bound to this exact transcript => no-op. In
558
- // particular do NOT re-run recoverUnsyncedTail, which is only meaningful
559
- // when the binding actually changes.
560
- if (forwarder.hasBinding() && sameResolvedPath(forwarder.transcriptPath, cleanPath)) return;
561
- // Binding changed: same path as the inbound steal's applyTranscriptBinding.
562
- applyTranscriptBinding(channelId, cleanPath, {
563
- persistStatus: true,
564
- recoverUnsyncedTail: true,
565
- });
566
- },
567
318
  startChannelBridge: () => start({ messaging: true }),
568
319
  stopOwnedRuntime,
569
320
  reloadRuntimeConfig,
570
321
  },
571
322
  });
572
- createInboundHandler({
573
- getProvider: () => provider,
574
- getConfig: () => config,
575
- getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
576
- getChannelBridgeActive: () => channelBridgeActive,
577
- instanceId: INSTANCE_ID,
578
- forwarder,
579
- scheduler,
580
- statusState,
581
- getBridgeOwnershipSnapshot,
582
- refreshBridgeOwnershipSafe,
583
- writeChannelOwner,
584
- shouldDropDuplicateInbound,
585
- resolveInboundRoute,
586
- isVoiceAttachment,
587
- transcribeVoice,
588
- getPersistedTranscriptPath,
589
- sessionIdFromTranscriptPath,
590
- pickUsableTranscriptPath,
591
- applyTranscriptBinding,
592
- rebindTranscriptContext,
593
- sendNotifyToParent,
594
- memoryAppendEntry,
595
- startServerTyping,
596
- stopServerTyping,
597
- });
598
323
  async function init(_sharedMcp) {
599
324
  // _sharedMcp is no longer used. Notifications now flow via IPC to the parent
600
325
  // (sendNotifyToParent above). The parameter is retained for backward
@@ -626,17 +351,11 @@ async function start(options = {}) {
626
351
  // Daemon model: this runtime is the machine-global singleton bridge owner
627
352
  // (enforced by the standalone daemon's singleton-owner lock), so there is no
628
353
  // seat to claim and no contender to make-before-break against. Just connect
629
- // the owned runtime and bind the persisted transcript.
630
- const _bindingReadyStart = Date.now();
354
+ // the owned runtime.
631
355
  try {
632
- await startOwnedRuntime({ restoreBinding: true });
633
- bindingReadyStatus = "resolved";
634
- dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
635
- _bindingReadyResolve(true);
636
- } catch (e) {
637
- bindingReadyStatus = "rejected";
638
- dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
639
- _bindingReadyResolve(e);
356
+ await startOwnedRuntime();
357
+ } catch {
358
+ // Non-fatal: owned-runtime start errors degrade to automation-only mode.
640
359
  }
641
360
  // No-op under the daemon model (kept for call-site stability): there is no
642
361
  // ownership timer — the singleton daemon guarantees exactly one owner.
@@ -668,10 +387,6 @@ async function stop() {
668
387
  clearBridgeOwnershipTimer();
669
388
  if (_reloadDebounce) { clearTimeout(_reloadDebounce); _reloadDebounce = null; }
670
389
  if (_configWatcher) { try { _configWatcher.close(); } catch {} _configWatcher = null; }
671
- if (turnEndWatcher) {
672
- try { turnEndWatcher.close(); } catch {}
673
- turnEndWatcher = null;
674
- }
675
390
  }
676
391
  // ── IPC worker mode ──────────────────────────────────────────────
677
392
  // Skipped under the machine-global host (MIXDOG_DAEMON_HOST=1): the daemon
@@ -14,7 +14,16 @@
14
14
  * what made the remote gallery slow.
15
15
  */
16
16
  import { spawn } from 'child_process';
17
- import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
17
+ import { randomBytes } from 'crypto';
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ renameSync,
23
+ statSync,
24
+ unlinkSync,
25
+ writeFileSync,
26
+ } from 'fs';
18
27
  import { dirname, join } from 'path';
19
28
 
20
29
  import { resolvePluginData } from '../shared/plugin-paths.mjs';
@@ -35,6 +44,12 @@ const POSTER_TIMEOUT_MS = 20_000;
35
44
  const MAX_POSTER_BYTES = 32 * 1024 * 1024;
36
45
  const FAILED_RENDITION_COOLDOWN_MS = 30_000;
37
46
  const MAX_CACHED_RENDITION_BYTES = 4 * 1024 * 1024;
47
+ const DEFAULT_RENDITION_CACHE_MAX_BYTES = 512 * 1024 * 1024;
48
+ const configuredRenditionCacheMaxBytes = Number(process.env.MIXDOG_RENDITION_CACHE_MAX_BYTES);
49
+ export const RENDITION_CACHE_MAX_BYTES = Number.isFinite(configuredRenditionCacheMaxBytes)
50
+ && configuredRenditionCacheMaxBytes >= MAX_CACHED_RENDITION_BYTES
51
+ ? Math.floor(configuredRenditionCacheMaxBytes)
52
+ : DEFAULT_RENDITION_CACHE_MAX_BYTES;
38
53
 
39
54
  const MEDIA_VARIANTS = Object.keys(SPECS);
40
55
 
@@ -89,14 +104,62 @@ export function removeRenditions(cacheDir, id) {
89
104
  }
90
105
  }
91
106
 
107
+ /** Bound the rebuildable rendition tree by evicting the oldest files first. */
108
+ export function pruneRenditionCache(cacheDir, {
109
+ maxBytes = RENDITION_CACHE_MAX_BYTES,
110
+ protectedPath = '',
111
+ } = {}) {
112
+ const budget = Math.max(0, Math.floor(Number(maxBytes) || 0));
113
+ const rows = [];
114
+ let totalBytes = 0;
115
+ for (const variant of MEDIA_VARIANTS) {
116
+ const dir = join(cacheDir, variant);
117
+ let names;
118
+ try { names = readdirSync(dir); } catch { continue; }
119
+ for (const name of names) {
120
+ if (!Object.keys(EXTENSION_MIME).some((extension) => name.endsWith(extension))) continue;
121
+ const path = join(dir, name);
122
+ try {
123
+ const stat = statSync(path);
124
+ if (!stat.isFile()) continue;
125
+ rows.push({ path, bytes: stat.size, mtimeMs: stat.mtimeMs });
126
+ totalBytes += stat.size;
127
+ } catch { /* raced with another writer/pruner */ }
128
+ }
129
+ }
130
+ rows.sort((a, b) => (a.mtimeMs - b.mtimeMs) || a.path.localeCompare(b.path));
131
+ for (const row of rows) {
132
+ if (totalBytes <= budget) break;
133
+ if (row.path === protectedPath) continue;
134
+ try {
135
+ unlinkSync(row.path);
136
+ totalBytes -= row.bytes;
137
+ } catch { /* another process already removed it */ }
138
+ }
139
+ return { bytes: Math.max(0, totalBytes), entries: rows.length };
140
+ }
141
+
92
142
  function writeRendition(cacheDir, variant, id, extension, buffer) {
143
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0 || buffer.length > MAX_CACHED_RENDITION_BYTES) {
144
+ return null;
145
+ }
93
146
  const path = renditionPath(cacheDir, variant, id, extension);
94
147
  mkdirSync(dirname(path), { recursive: true });
95
148
  // Stage-then-rename: a reader must never observe a half-written cache file.
96
- const staging = `${path}.${process.pid}.tmp`;
149
+ const staging = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
97
150
  writeFileSync(staging, buffer);
98
- renameSync(staging, path);
99
- return { path, mime: EXTENSION_MIME[extension], bytes: buffer.length };
151
+ try {
152
+ renameSync(staging, path);
153
+ } catch (error) {
154
+ // Another process may have won the same cache publication race.
155
+ if (!existsSync(path)) throw error;
156
+ } finally {
157
+ try { unlinkSync(staging); } catch { /* renamed or already removed */ }
158
+ }
159
+ let bytes = buffer.length;
160
+ try { bytes = statSync(path).size; } catch { /* use written size */ }
161
+ pruneRenditionCache(cacheDir, { protectedPath: path });
162
+ return { path, mime: EXTENSION_MIME[extension], bytes };
100
163
  }
101
164
 
102
165
  export function createPriorityScheduler(limit = 2) {
@@ -246,7 +309,7 @@ async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
246
309
  const frame = await grabVideoFrame(sourcePath, spec);
247
310
  if (!frame) return null;
248
311
  const written = writeRendition(cacheDir, variant, id, '.jpg', frame.buffer);
249
- return { ...written, durationSeconds: frame.durationSeconds };
312
+ return written ? { ...written, durationSeconds: frame.durationSeconds } : null;
250
313
  }
251
314
  const encoded = await encodeStill(sourcePath, spec);
252
315
  return encoded ? writeRendition(cacheDir, variant, id, encoded.extension, encoded.buffer) : null;
@@ -1,8 +1,18 @@
1
1
  import assert from 'node:assert/strict';
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ rmSync,
6
+ utimesSync,
7
+ } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
2
10
  import { test } from 'node:test';
3
11
 
4
12
  import {
13
+ cacheRendition,
5
14
  createPriorityScheduler,
15
+ pruneRenditionCache,
6
16
  videoPosterArguments,
7
17
  } from './renditions.mjs';
8
18
 
@@ -52,3 +62,38 @@ test('rendition scheduler bounds work and prioritizes visible requests over queu
52
62
  await Promise.all([first, second, third]);
53
63
  assert.equal(peak, 2);
54
64
  });
65
+
66
+ test('rendition files reject oversized entries and obey a total disk budget', () => {
67
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-rendition-cache-'));
68
+ try {
69
+ const first = cacheRendition({
70
+ id: 'first',
71
+ variant: 'thumb',
72
+ mime: 'image/jpeg',
73
+ buffer: Buffer.alloc(10, 1),
74
+ cacheDir: root,
75
+ });
76
+ const second = cacheRendition({
77
+ id: 'second',
78
+ variant: 'thumb',
79
+ mime: 'image/jpeg',
80
+ buffer: Buffer.alloc(10, 2),
81
+ cacheDir: root,
82
+ });
83
+ assert.ok(first && second);
84
+ utimesSync(first.path, new Date(1_000), new Date(1_000));
85
+ const pruned = pruneRenditionCache(root, { maxBytes: 10 });
86
+ assert.equal(pruned.bytes, 10);
87
+ assert.equal(existsSync(first.path), false);
88
+ assert.equal(existsSync(second.path), true);
89
+ assert.equal(cacheRendition({
90
+ id: 'oversized',
91
+ variant: 'thumb',
92
+ mime: 'image/jpeg',
93
+ buffer: Buffer.alloc(4 * 1024 * 1024 + 1),
94
+ cacheDir: root,
95
+ }), null);
96
+ } finally {
97
+ rmSync(root, { recursive: true, force: true });
98
+ }
99
+ });
@@ -217,6 +217,7 @@ export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime,
217
217
  for (const dropped of assets.slice(MAX_INDEX_ENTRIES)) {
218
218
  const droppedPath = storedAssetPath(dropped.file);
219
219
  try { if (droppedPath) unlinkSync(droppedPath); } catch {}
220
+ removeRenditions(renditionsDir(), dropped.id);
220
221
  }
221
222
  writeIndex(kept);
222
223
  });
@@ -58,7 +58,7 @@ import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
58
58
  import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, flushRawEmbeddings, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
59
59
  import { callAgentDispatch } from './lib/agent-ipc.mjs'
60
60
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
61
- import { claimAndMarkScheduledCycle, markCycleRequest, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
61
+ import { cancelCoalescedCycleRetries, claimAndMarkScheduledCycle, markCycleRequest, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
62
62
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
63
63
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
64
64
  import { retrieveEntries } from './lib/memory-retrievers.mjs'
@@ -420,6 +420,7 @@ const _cycleScheduler = createCycleScheduler({
420
420
  markCycleRequest,
421
421
  resolveCoalesceMaxRetries,
422
422
  scheduleCoalescedCycleRetry,
423
+ cancelCoalescedCycleRetries,
423
424
  scheduledCycle1Signature,
424
425
  scheduledCycle2Signature,
425
426
  scheduledCycle3Signature,
@@ -643,8 +644,8 @@ export async function init() {
643
644
  await runtimeReady
644
645
  } catch (e) {
645
646
  // Runtime init failed AFTER we advertised the HTTP port. Leaving the
646
- // listener up would answer discovery with a live 503, which
647
- // memory-client treats as a delivered (non-buffered) write — silently
647
+ // listener up would answer discovery with a live 503, which HTTP
648
+ // ingest callers treat as a delivered (non-buffered) write — silently
648
649
  // dropping entries that would otherwise be buffered when no port is
649
650
  // advertised. stop() withdraws the advert (clears _currentAdvertisedPort
650
651
  // + cancels the periodic re-advertise) and closes the HTTP server, so