mixdog 0.9.135 → 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 (121) 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/help.mjs +0 -2
  6. package/src/rules/lead/01-general.md +0 -4
  7. package/src/rules/shared/01-tool.md +10 -4
  8. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +30 -4
  9. package/src/runtime/agent/orchestrator/agent-trace-timing.test.mjs +57 -0
  10. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +3 -3
  11. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.test.mjs +40 -0
  12. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -0
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +0 -5
  14. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +17 -4
  15. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +40 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +150 -41
  17. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +34 -0
  19. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +10 -9
  21. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +124 -18
  22. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-top-k.test.mjs +39 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +7 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +4 -1
  25. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +82 -5
  26. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  28. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +78 -12
  29. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +34 -3
  30. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -5
  31. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +87 -0
  32. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  33. package/src/runtime/channels/lib/config.mjs +14 -101
  34. package/src/runtime/channels/lib/index-drop-trace.mjs +2 -5
  35. package/src/runtime/channels/lib/owned-runtime.mjs +4 -80
  36. package/src/runtime/channels/lib/scheduler.mjs +75 -18
  37. package/src/runtime/channels/lib/status-snapshot.mjs +5 -68
  38. package/src/runtime/channels/lib/tool-dispatch.mjs +12 -55
  39. package/src/runtime/channels/lib/worker-ipc.mjs +1 -16
  40. package/src/runtime/channels/lib/worker-main.mjs +22 -307
  41. package/src/runtime/media/renditions.mjs +68 -5
  42. package/src/runtime/media/renditions.test.mjs +45 -0
  43. package/src/runtime/media/store.mjs +1 -0
  44. package/src/runtime/memory/index.mjs +4 -3
  45. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -6
  46. package/src/runtime/memory/lib/memory-cycle-packets.test.mjs +7 -1
  47. package/src/runtime/memory/lib/memory-cycle-requests.mjs +13 -0
  48. package/src/runtime/shared/config.mjs +8 -49
  49. package/src/runtime/shared/schedule-time.mjs +25 -0
  50. package/src/runtime/shared/schedule-time.test.mjs +29 -0
  51. package/src/runtime/shared/schedules-db.mjs +73 -1
  52. package/src/runtime/shared/tool-card-model.mjs +1 -1
  53. package/src/runtime/shared/transcript-writer.mjs +10 -17
  54. package/src/runtime/shared/update-checker.mjs +7 -2
  55. package/src/runtime/shared/update-checker.test.mjs +33 -0
  56. package/src/session-runtime/channel-config-api.mjs +3 -20
  57. package/src/session-runtime/config-lifecycle.mjs +1 -42
  58. package/src/session-runtime/lifecycle-api.mjs +1 -19
  59. package/src/session-runtime/model-route-api.mjs +0 -2
  60. package/src/session-runtime/model-settings-persist.test.mjs +0 -1
  61. package/src/session-runtime/prewarm.mjs +2 -7
  62. package/src/session-runtime/prewarm.test.mjs +0 -1
  63. package/src/session-runtime/runtime-core.mjs +6 -185
  64. package/src/session-runtime/session-lifecycle.mjs +4 -17
  65. package/src/session-runtime/session-transcript.mjs +54 -0
  66. package/src/session-runtime/session-turn-api.mjs +4 -44
  67. package/src/session-runtime/settings-api.mjs +0 -45
  68. package/src/session-runtime/tool-catalog-schema.mjs +1 -1
  69. package/src/standalone/channel-admin.mjs +8 -150
  70. package/src/standalone/daemon.mjs +5 -48
  71. package/src/standalone/seeds.mjs +18 -1
  72. package/src/standalone/session-protocol.mjs +0 -8
  73. package/src/standalone/session-service.mjs +1 -3
  74. package/src/tui/App.jsx +0 -5
  75. package/src/tui/app/app-view.jsx +1 -2
  76. package/src/tui/app/create-app-pickers.mjs +0 -20
  77. package/src/tui/app/doctor.mjs +2 -6
  78. package/src/tui/app/prompt-submit.mjs +2 -49
  79. package/src/tui/app/settings-picker.mjs +22 -96
  80. package/src/tui/app/slash-commands.mjs +0 -2
  81. package/src/tui/app/slash-dispatch.mjs +0 -17
  82. package/src/tui/app/use-prompt-handlers.mjs +4 -1
  83. package/src/tui/components/StatusLine.jsx +4 -4
  84. package/src/tui/dist/index.mjs +1003 -1461
  85. package/src/tui/session/context-state.mjs +0 -1
  86. package/src/tui/session/queue-helpers.mjs +1 -1
  87. package/src/tui/session/queue-helpers.test.mjs +10 -1
  88. package/src/tui/session/session-api-ext.mjs +0 -89
  89. package/src/tui/session/session-api.mjs +0 -2
  90. package/src/tui/session/tool-card-results.mjs +1 -1
  91. package/src/tui/session/tool-result-status.mjs +3 -3
  92. package/src/tui/session/tool-result-status.test.mjs +3 -3
  93. package/src/tui/session-local.mjs +0 -26
  94. package/src/workflows/default/WORKFLOW.md +13 -15
  95. package/src/workflows/solo/WORKFLOW.md +6 -7
  96. package/src/runtime/channels/lib/config-lock.mjs +0 -13
  97. package/src/runtime/channels/lib/drop-trace.mjs +0 -71
  98. package/src/runtime/channels/lib/format.mjs +0 -190
  99. package/src/runtime/channels/lib/inbound-handler.mjs +0 -387
  100. package/src/runtime/channels/lib/inbound-routing.mjs +0 -81
  101. package/src/runtime/channels/lib/interaction-handlers.mjs +0 -70
  102. package/src/runtime/channels/lib/interaction-workflows.mjs +0 -76
  103. package/src/runtime/channels/lib/memory-client.mjs +0 -289
  104. package/src/runtime/channels/lib/output-forwarder.mjs +0 -934
  105. package/src/runtime/channels/lib/session-control.mjs +0 -36
  106. package/src/runtime/channels/lib/session-discovery.mjs +0 -217
  107. package/src/runtime/channels/lib/session-discovery.test.mjs +0 -32
  108. package/src/runtime/channels/lib/telegram-format.mjs +0 -280
  109. package/src/runtime/channels/lib/tool-format.mjs +0 -125
  110. package/src/runtime/channels/lib/transcript-binding.mjs +0 -336
  111. package/src/runtime/channels/lib/transcript-discovery.mjs +0 -208
  112. package/src/runtime/channels/providers/discord-access.mjs +0 -30
  113. package/src/runtime/channels/providers/discord-attachments.mjs +0 -69
  114. package/src/runtime/channels/providers/discord-gateway.mjs +0 -230
  115. package/src/runtime/channels/providers/discord.mjs +0 -730
  116. package/src/runtime/channels/providers/telegram.mjs +0 -598
  117. package/src/runtime/channels/providers/telegram.test.mjs +0 -75
  118. package/src/session-runtime/remote-control.mjs +0 -126
  119. package/src/session-runtime/remote-transcript.mjs +0 -134
  120. package/src/session-runtime/remote-transition-queue.mjs +0 -14
  121. package/src/tui/app/channel-pickers.mjs +0 -384
@@ -1,16 +1,9 @@
1
1
  import { readFileSync, mkdirSync } from "fs";
2
2
  import { join } from "path";
3
- import { DiscordProvider } from "../providers/discord.mjs";
4
- import { TelegramProvider } from "../providers/telegram.mjs";
5
3
  import {
6
4
  readSection,
7
5
  updateSection,
8
6
  CONFIG_PATH as MIXDOG_CONFIG_PATH,
9
- SECRET_ACCOUNTS,
10
- getDiscordToken,
11
- getTelegramToken,
12
- diagnoseDiscordTokenValue,
13
- invalidateSecretReadCache,
14
7
  } from "../../shared/config.mjs";
15
8
  import { listSchedules } from "../../shared/schedules-db.mjs";
16
9
  import { resolvePluginData } from "../../shared/plugin-paths.mjs";
@@ -22,9 +15,6 @@ const DEFAULT_ACCESS = {
22
15
  channels: {}
23
16
  };
24
17
  const DEFAULT_CONFIG = {
25
- provider: "discord",
26
- discord: { token: "" },
27
- telegram: { token: "" },
28
18
  access: DEFAULT_ACCESS,
29
19
  channel: {}
30
20
  };
@@ -40,27 +30,8 @@ function applyDefaults(config) {
40
30
  return out;
41
31
  }
42
32
 
43
- function channelIdForProvider(entry = {}, provider = "discord") {
44
- if (provider === "telegram") {
45
- return String(entry?.telegramChatId || "");
46
- }
47
- return String(entry?.discordChannelId || "");
48
- }
49
-
50
- // Resolve the single active-provider channel id from the config's `channel`
51
- // section. Disk config carries a single `channel` object only.
52
- function resolveChannelId(raw = {}, provider = "discord") {
53
- const channel = raw.channel && typeof raw.channel === "object" ? raw.channel : null;
54
- if (channel) return channelIdForProvider(channel, provider);
55
- return "";
56
- }
57
-
58
- async function loadConfig({ freshSecrets = false } = {}) {
33
+ async function loadConfig() {
59
34
  try {
60
- if (freshSecrets) {
61
- invalidateSecretReadCache(SECRET_ACCOUNTS.discordToken);
62
- invalidateSecretReadCache(SECRET_ACCOUNTS.telegramToken);
63
- }
64
35
  let raw = readSection("channels");
65
36
  raw = raw && typeof raw === "object" ? raw : {};
66
37
  // Schedules are the PG `scheduler.schedules` table (single source of
@@ -83,27 +54,11 @@ async function loadConfig({ freshSecrets = false } = {}) {
83
54
  const v = readSection("voice");
84
55
  if (v && typeof v === "object") voice = v;
85
56
  } catch { /* missing section is fine */ }
86
- const discordToken = getDiscordToken();
87
- const discordTokenProblem = diagnoseDiscordTokenValue(discordToken, raw);
88
- if (discordTokenProblem) {
89
- process.stderr.write(`mixdog: discord token ignored: ${discordTokenProblem}\n`);
90
- }
91
- // Single-provider select: config.provider picks ONE of discord|telegram.
92
- // Anything else falls back to the discord default.
93
- const provider = raw.provider === "telegram" ? "telegram" : "discord";
94
- const telegramToken = getTelegramToken();
95
- const channelId = resolveChannelId(raw, provider);
96
- // The runtime reads only the resolved `channelId`; disk carries a single
97
- // `channel` object, so spread `raw` directly.
57
+ // Messaging is retired: no provider select, no tokens, no channel id.
98
58
  return applyDefaults({
99
59
  ...DEFAULT_CONFIG,
100
60
  ...raw,
101
- provider,
102
- channelId,
103
- discord: { ...DEFAULT_CONFIG.discord, ...(({ token: _, ...rest }) => rest)(raw.discord || {}), ...(discordToken && !discordTokenProblem ? { token: discordToken } : {}) },
104
- // Merge the keychain-resolved telegram token (harmless when provider is
105
- // discord; the secret never lands in the on-disk config either way).
106
- telegram: { ...DEFAULT_CONFIG.telegram, ...(({ token: _t, ...rest }) => rest)(raw.telegram || {}), ...(telegramToken ? { token: telegramToken } : {}) },
61
+ channelId: "",
107
62
  access: {
108
63
  ...DEFAULT_ACCESS,
109
64
  ...(raw.access || {}),
@@ -116,9 +71,7 @@ async function loadConfig({ freshSecrets = false } = {}) {
116
71
  mkdirSync(DATA_DIR, { recursive: true });
117
72
  updateSection("channels", () => DEFAULT_CONFIG);
118
73
  process.stderr.write(
119
- `mixdog: default channels config created in ${MIXDOG_CONFIG_PATH}
120
- edit the active provider channel id to connect.
121
- `
74
+ `mixdog: default channels config created in ${MIXDOG_CONFIG_PATH}\n`
122
75
  );
123
76
  return applyDefaults(DEFAULT_CONFIG);
124
77
  }
@@ -154,56 +107,18 @@ const HEADLESS_PROVIDER = {
154
107
  return Buffer.alloc(0);
155
108
  },
156
109
  on() {
110
+ },
111
+ startTyping() {
112
+ },
113
+ stopTyping() {
157
114
  }
158
115
  };
159
- function createProvider(config) {
160
- // Channels-module toggle is MESSAGING-ONLY: automation (scheduler/webhooks)
161
- // keeps the worker alive, so a disabled module runs the headless provider
162
- // instead of blocking the whole runtime.
163
- if (config.enabled === false) {
164
- process.stderr.write("mixdog: channels messaging disabled; channel runtime running in headless mode\n");
165
- return HEADLESS_PROVIDER;
166
- }
167
- // Single-provider select: exactly one provider is constructed based on
168
- // config.provider (discord|telegram). The two are mutually exclusive.
169
- if (config.provider === "telegram") {
170
- const telegramToken = getTelegramToken();
171
- if (!telegramToken) {
172
- process.stderr.write("mixdog: telegram bot not configured; channel runtime running in headless mode\n");
173
- return HEADLESS_PROVIDER;
174
- }
175
- const tgStateDir = config.telegram?.stateDir ?? join(DATA_DIR, "telegram");
176
- mkdirSync(tgStateDir, { recursive: true });
177
- return new TelegramProvider({
178
- ...config.telegram,
179
- configPath: CONFIG_FILE,
180
- access: config.access,
181
- // Single-source channel setup: the main chat is auto-allowed inside
182
- // TelegramProvider.loadAccess() so the configured Telegram target is
183
- // enough for both inbound gating and outbound.
184
- mainChannelId: config.channelId
185
- }, tgStateDir);
186
- }
187
- const discordToken = getDiscordToken();
188
- const discordTokenProblem = diagnoseDiscordTokenValue(discordToken, config);
189
- if (discordTokenProblem) {
190
- process.stderr.write(`mixdog: discord token ignored: ${discordTokenProblem}\n`);
191
- }
192
- if (config.provider !== "discord" || !discordToken || discordTokenProblem) {
193
- process.stderr.write("mixdog: discord bot not configured; channel runtime running in headless mode\n");
194
- return HEADLESS_PROVIDER;
195
- }
196
- const stateDir = config.discord.stateDir ?? join(DATA_DIR, "discord");
197
- mkdirSync(stateDir, { recursive: true });
198
- return new DiscordProvider({
199
- ...config.discord,
200
- configPath: CONFIG_FILE,
201
- access: config.access,
202
- // Single-source channel setup: the main channel is auto-allowed inside
203
- // DiscordProvider.loadAccess() so the configured Discord target is enough
204
- // for both inbound and outbound — no separate access.channels entry.
205
- mainChannelId: config.channelId
206
- }, stateDir);
116
+ function createProvider() {
117
+ // Discord/Telegram messaging is retired (user decision: the PWA replaces
118
+ // it). The channel runtime always runs headless: automation
119
+ // (scheduler/webhooks) and voice transcription keep the worker alive while
120
+ // every messaging call is a no-op.
121
+ return HEADLESS_PROVIDER;
207
122
  }
208
123
  const PROFILE_FILE = join(DATA_DIR, "profile.json");
209
124
  function loadProfileConfig() {
@@ -216,8 +131,6 @@ function loadProfileConfig() {
216
131
  export {
217
132
  DATA_DIR,
218
133
  createProvider,
219
- getDiscordToken,
220
- getTelegramToken,
221
134
  loadConfig,
222
135
  loadProfileConfig
223
136
  };
@@ -2,11 +2,8 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { DATA_DIR } from "./config.mjs";
4
4
 
5
- // Drop-trace instrumentation for channels/index.mjs.
6
- // Extracted verbatim (behavior-preserving). Distinct from lib/drop-trace.mjs
7
- // (which the output-forwarder uses): this instance is owned by index.mjs and
8
- // keeps its own buffered writer + `preview` helper so the two trace streams
9
- // stay byte-identical to the pre-split behavior.
5
+ // Drop-trace instrumentation for channels/index.mjs: buffered writer +
6
+ // `preview` helper owned by index.mjs.
10
7
  const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
11
8
  const DROP_TRACE_ENABLED =
12
9
  process.env.MIXDOG_DROP_TRACE === "1" ||
@@ -31,24 +31,17 @@ export function createOwnedRuntime({
31
31
  getChannelBridgeActive,
32
32
  instanceId,
33
33
  TERMINAL_LEAD_PID,
34
- forwarder,
35
34
  sendNotifyToParent,
36
35
  scheduler,
37
36
  statusState,
38
37
  logOwnership,
39
38
  currentOwnerState,
40
- bindPersistedTranscriptIfAny,
41
- cancelPendingTranscriptRearm,
42
- schedulePendingTranscriptRearm,
43
- stopServerTyping,
44
39
  wireWebhookHandlers,
45
40
  wireEventQueueHandlers,
46
- memoryDrainBuffer,
47
41
  }) {
48
42
  let bridgeRuntimeStarting = false;
49
43
  let _ownedRuntimeStopRequested = false;
50
44
  let bridgeOwnershipRefreshInFlight = null;
51
- let _memoryDrainTimer = null;
52
45
  // Automation (scheduler + webhook server + relay tunnel) can outlive a
53
46
  // failed/absent messaging provider: it is tracked separately so teardown and
54
47
  // reload know it is running even while bridgeRuntimeConnected stays false.
@@ -174,27 +167,16 @@ let _ownedRuntimeSelfHealing = false;
174
167
  // reconnect window can reset a healthy reconnect loop. Keep this for explicit
175
168
  // activation/reload recovery only.
176
169
  async function selfHealOwnedRuntime(options = {}) {
177
- const shouldRestoreBinding = options.restoreBinding === true;
178
170
  const shouldResetProvider = options.resetProvider === true;
179
- if (!shouldRestoreBinding && !shouldResetProvider) return;
171
+ if (!shouldResetProvider) return;
180
172
  if (_ownedRuntimeSelfHealing) return;
181
173
  _ownedRuntimeSelfHealing = true;
182
174
  try {
183
- if (shouldRestoreBinding) {
184
- await bindPersistedTranscriptIfAny().catch((e) => {
185
- process.stderr.write(`mixdog: self-heal bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
186
- });
187
- }
188
- if (shouldResetProvider && typeof getProvider()?._resetClient === "function") {
175
+ if (typeof getProvider()?._resetClient === "function") {
189
176
  await getProvider()._resetClient().catch((e) => {
190
177
  process.stderr.write(`mixdog: self-heal getProvider() reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
191
178
  });
192
179
  }
193
- // Do NOT nudge forwardNewText() here. bindPersistedTranscriptIfAny() above
194
- // already forwards the same-session catch-up from the persisted cursor; an
195
- // extra drain risks surfacing the old tail of a freshly (re)bound transcript
196
- // on connect/change/rebind. Only outputs created after the new binding
197
- // should be forwarded, and the normal watch/poll path handles those.
198
180
  } finally {
199
181
  _ownedRuntimeSelfHealing = false;
200
182
  }
@@ -237,13 +219,6 @@ async function startOwnedRuntime(options = {}) {
237
219
  process.stderr.write(`mixdog: pre-connect metadata advert aborted (${e instanceof Error ? e.message : String(e)})\n`);
238
220
  return;
239
221
  }
240
- // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
241
- // daemon-hosted memory runtime is ready. Idempotent + reentrancy-guarded inside
242
- // drainBuffer(); unref'd so it never holds the worker open.
243
- if (!_memoryDrainTimer) {
244
- _memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
245
- _memoryDrainTimer.unref?.();
246
- }
247
222
  // Re-check after each post-connect await so a stopOwnedRuntime() landing
248
223
  // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
249
224
  // webhook/binding launches below would revive owner state after stop).
@@ -260,12 +235,6 @@ async function startOwnedRuntime(options = {}) {
260
235
  _ownedRuntimeStopRequested = false;
261
236
  return true;
262
237
  };
263
- const restoreBinding = options.restoreBinding !== false;
264
- const bindPersistedTranscriptTask = restoreBinding
265
- ? bindPersistedTranscriptIfAny().catch((e) => {
266
- process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
267
- })
268
- : null;
269
238
  // Await getProvider().connect() so callers (and bindingReady) only resolve after
270
239
  // the Discord binding is real. Previously this was fire-and-forget and
271
240
  // refreshBridgeOwnership returned immediately, letting bindingReady fire
@@ -273,9 +242,6 @@ async function startOwnedRuntime(options = {}) {
273
242
  try {
274
243
  await startingProvider.connect();
275
244
  if (await bailIfStopRequested()) {
276
- cancelPendingTranscriptRearm();
277
- try { forwarder.stopWatch(); } catch {}
278
- if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
279
245
  return;
280
246
  }
281
247
  // Advertise provider readiness (metadata advert).
@@ -301,9 +267,6 @@ async function startOwnedRuntime(options = {}) {
301
267
  process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
302
268
  }
303
269
  if (await bailIfStopRequested()) {
304
- cancelPendingTranscriptRearm();
305
- try { forwarder.stopWatch(); } catch {}
306
- if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
307
270
  return;
308
271
  }
309
272
  // Reconnect after a degraded (automation-only) start must not double-arm
@@ -314,26 +277,10 @@ async function startOwnedRuntime(options = {}) {
314
277
  }
315
278
  syncOwnedWebhookAndEventRuntime();
316
279
  automationRunning = true;
317
- if (restoreBinding) {
318
- if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
319
- const pendingTranscriptPath = forwarder.transcriptPath;
320
- if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
321
- // Pre-connect bind may have armed rearm while !getBridgeRuntimeConnected();
322
- // the first tick then exits without rescheduling. Re-arm now that we own.
323
- schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
324
- } else {
325
- void forwarder.forwardNewText().catch((err) => {
326
- process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
327
- });
328
- }
329
- }
330
280
  process.stderr.write(`mixdog: running with ${getProvider().name} getProvider()\n`);
331
281
  logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
332
282
  } catch (e) {
333
283
  process.stderr.write(`mixdog: getProvider() connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
334
- cancelPendingTranscriptRearm();
335
- try { forwarder.stopWatch(); } catch {}
336
- if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
337
284
  // Roll back partial owner-side state advertised before connect() ran:
338
285
  // disconnect the provider WE started (a post-connect startup step may have
339
286
  // thrown while the gateway is live), then release the channel locks + clear
@@ -341,7 +288,6 @@ async function startOwnedRuntime(options = {}) {
341
288
  try { await startingProvider.disconnect(); } catch {}
342
289
  try { releaseOwnedChannelLocks(instanceId); } catch {}
343
290
  try { clearActiveInstance(instanceId); } catch {}
344
- if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
345
291
  // DEGRADED MODE (automation decoupling): a messaging connect failure —
346
292
  // packaged runtime without discord.js, bad token, gateway outage — must
347
293
  // not take schedules/webhooks down with it. Start the automation runtime
@@ -364,11 +310,6 @@ async function startOwnedRuntime(options = {}) {
364
310
  }
365
311
  }
366
312
  async function stopOwnedRuntime(reason) {
367
- // Cancel any pending transcript re-arm poll BEFORE the connected/starting
368
- // early-return below. Otherwise a poll armed against a not-yet-existing
369
- // transcript could fire after teardown and reinstall the fs.watch handle
370
- // (startWatch is not owner-gated), leaking a live watcher past shutdown.
371
- cancelPendingTranscriptRearm();
372
313
  // startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
373
314
  // claims channel locks BEFORE awaiting getProvider().connect(). If shutdown lands
374
315
  // during that window (bridgeRuntimeStarting=true, getBridgeRuntimeConnected()
@@ -384,13 +325,6 @@ async function stopOwnedRuntime(reason) {
384
325
  try { await automationStartPromise; } catch {}
385
326
  }
386
327
  const wasConnected = getBridgeRuntimeConnected();
387
- stopServerTyping();
388
- // Release the transcript fs.watch handle plus the forwarder's debounce/retry
389
- // timers on standby. Without this the watcher keeps firing scheduleWatchFlush
390
- // and the drain/retry timers stay live after ownership is dropped, leaking a
391
- // file handle + timers for the rest of the process lifetime.
392
- try { forwarder.stopWatch(); } catch {}
393
- if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
394
328
  scheduler.stop();
395
329
  stopSnapshotWriter();
396
330
  await stopWebhookAndEventRuntime();
@@ -499,19 +433,9 @@ async function reloadRuntimeConfig() {
499
433
  // the restart and the NEW provider would never connect (lost-restart race).
500
434
  if (_inFlightStart) { try { await _inFlightStart; } catch {} }
501
435
  setProvider(nextProvider);
502
- // The persisted routing channelId belongs to the OLD getProvider() (e.g. a
503
- // Discord snowflake) and is meaningless for the new one — sending to it
504
- // would 400 "chat not found". There is no id mapping between platforms, so
505
- // CLEAR the stale binding: drop the forwarder's context + watcher and wipe
506
- // status.channelId/transcriptPath. The next inbound from the new getProvider()
507
- // rebinds the correct chat via applyTranscriptBinding(). Only done on
508
- // providerChanged — same-getProvider() reloads keep their binding untouched.
509
- // (active-instance is cleared by stopOwnedRuntime on the restart path; we
510
- // don't re-advertise here to avoid resurrecting a just-cleared entry.)
436
+ // Provider-type change wipes the persisted routing ids; messaging is
437
+ // retired, so only the status snapshot needs clearing.
511
438
  if (providerTypeChanged) {
512
- try { forwarder.stopWatch(); } catch {}
513
- forwarder.channelId = "";
514
- forwarder.transcriptPath = "";
515
439
  try {
516
440
  statusState.update((state) => {
517
441
  state.channelId = "";
@@ -6,7 +6,17 @@ import { DATA_DIR } from "./config.mjs";
6
6
  import { ensureNopluginDir } from "./executor.mjs";
7
7
  import { withFileLockSync } from "../../shared/atomic-file.mjs";
8
8
  import { resolveRuntimeRoot } from "../../shared/runtime-root.mjs";
9
- import { markFired, markDone, setDeferred, setSkippedUntil } from "../../shared/schedules-db.mjs";
9
+ import {
10
+ advanceScheduleCursor,
11
+ claimScheduleRun,
12
+ markFired,
13
+ markDone,
14
+ markScheduleFailure,
15
+ markScheduleSuccess,
16
+ setDeferred,
17
+ setNextFire,
18
+ setSkippedUntil,
19
+ } from "../../shared/schedules-db.mjs";
10
20
  import { runScheduleSession } from "../../shared/schedule-session-run.mjs";
11
21
 
12
22
  const SCHEDULE_LOG = join(DATA_DIR, "schedule.log");
@@ -107,6 +117,9 @@ class Scheduler {
107
117
  this.interactive = interactive.filter((s) => s.enabled !== false);
108
118
  this.channelId = channelId ?? "";
109
119
  this.promptsDir = join(DATA_DIR, "prompts");
120
+ for (const s of [...this.nonInteractive, ...this.interactive]) {
121
+ if (s.lastFiredAt) this.lastFired.set(s.name, new Date(s.lastFiredAt).toISOString());
122
+ }
110
123
  this.refreshSkipCache();
111
124
  }
112
125
  setInjectHandler(fn) {
@@ -341,11 +354,18 @@ ${Scheduler.INSTANCE_UUID}`;
341
354
  continue;
342
355
  }
343
356
  try {
344
- const task = cron.schedule(s.whenCron, () => this.onCronFire(s, type), {
357
+ const task = cron.schedule(s.whenCron, (ctx) => this.onCronFire(s, type, ctx?.date), {
345
358
  timezone: s.timezone || undefined,
346
359
  name: s.name,
347
360
  });
348
361
  this.cronJobs.set(s.name, task);
362
+ const next = typeof task.getNextRun === "function" ? task.getNextRun() : null;
363
+ if (next) {
364
+ s.nextFireAt = next.toISOString();
365
+ void setNextFire(s.name, next).catch((err) => {
366
+ process.stderr.write(`mixdog scheduler: ${s.name} setNextFire failed: ${err}\n`);
367
+ });
368
+ }
349
369
  logSchedule(`registered cron "${s.name}" = "${s.whenCron}"${s.timezone ? ` tz=${s.timezone}` : ""}\n`);
350
370
  } catch (err) {
351
371
  process.stderr.write(`mixdog scheduler: failed to register cron "${s.name}" (${s.whenCron}): ${err}\n`);
@@ -357,20 +377,44 @@ ${Scheduler.INSTANCE_UUID}`;
357
377
  }
358
378
  /** Fire path for a cron-triggered entry. node-cron's dow field covers the
359
379
  * day guard, so there is no separate days filter. Persists last_fired_at. */
360
- async onCronFire(schedule, type) {
361
- const now = /* @__PURE__ */ new Date();
362
- if (this.shouldSkip(schedule.name)) return;
363
- // Record lastFired only when the fire actually proceeds past
364
- // fireTimed's running/precondition guards (it resolves truthy on a
365
- // real fire), so failed/skipped fires no longer display as fired.
366
- this.fireTimed(schedule, type).then(async (fired) => {
367
- if (!fired) return;
368
- this.lastFired.set(schedule.name, now.toISOString());
369
- try { await markFired(schedule.name, now); }
370
- catch (err) { process.stderr.write(`mixdog scheduler: ${schedule.name} markFired failed: ${err}\n`); }
371
- }).catch(
372
- (err) => process.stderr.write(`mixdog scheduler: ${schedule.name} failed: ${err}\n`)
373
- );
380
+ async onCronFire(schedule, type, scheduledDate = null) {
381
+ const startedAt = /* @__PURE__ */ new Date();
382
+ const scheduledAt = scheduledDate instanceof Date && Number.isFinite(scheduledDate.getTime())
383
+ ? scheduledDate
384
+ : startedAt;
385
+ const task = this.cronJobs.get(schedule.name);
386
+ const nextFireAt = typeof task?.getNextRun === "function" ? task.getNextRun() : null;
387
+ schedule.nextFireAt = nextFireAt?.toISOString?.() ?? null;
388
+ if (this.shouldSkip(schedule.name)) {
389
+ try { await advanceScheduleCursor(schedule.name, scheduledAt, nextFireAt); }
390
+ catch (err) { process.stderr.write(`mixdog scheduler: ${schedule.name} cursor advance failed: ${err}\n`); }
391
+ return;
392
+ }
393
+ try {
394
+ const claimed = await claimScheduleRun(schedule.name, scheduledAt, startedAt, nextFireAt);
395
+ if (!claimed) {
396
+ logSchedule(`${schedule.name}: duplicate/stale cron slot skipped\n`);
397
+ return;
398
+ }
399
+ schedule.lastScheduledAt = scheduledAt.toISOString();
400
+ schedule.lastStartedAt = startedAt.toISOString();
401
+ schedule.lastFiredAt = startedAt.toISOString();
402
+ this.lastFired.set(schedule.name, startedAt.toISOString());
403
+ const fired = await this.fireTimed(schedule, type, { awaitDispatch: true });
404
+ if (!fired) {
405
+ schedule.lastFailedAt = new Date().toISOString();
406
+ await markScheduleFailure(schedule.name, new Date());
407
+ return;
408
+ }
409
+ const completedAt = new Date();
410
+ schedule.lastSuccessAt = completedAt.toISOString();
411
+ await markScheduleSuccess(schedule.name, completedAt);
412
+ } catch (err) {
413
+ schedule.lastFailedAt = new Date().toISOString();
414
+ try { await markScheduleFailure(schedule.name, new Date()); } catch {}
415
+ process.stderr.write(`mixdog scheduler: ${schedule.name} failed: ${err}\n`);
416
+ this.notifyFailure(schedule, `run failed: ${err?.message || err}`);
417
+ }
374
418
  }
375
419
  stop() {
376
420
  if (this.tickTimer) {
@@ -419,6 +463,8 @@ ${Scheduler.INSTANCE_UUID}`;
419
463
  }
420
464
  const timer = setTimeout(() => this.fireOneShot(schedule, type), Math.max(0, delay));
421
465
  this.oneShotTimers.set(schedule.name, timer);
466
+ schedule.nextFireAt = new Date(fireAt).toISOString();
467
+ void setNextFire(schedule.name, new Date(fireAt)).catch(() => {});
422
468
  logSchedule(`armed one-shot "${schedule.name}" at ${new Date(fireAt).toISOString()}${delay <= 0 ? " (misfire → immediate)" : ""}\n`);
423
469
  }
424
470
  /** markDone with bounded retry. A one-shot that fired but failed to persist
@@ -462,6 +508,8 @@ ${Scheduler.INSTANCE_UUID}`;
462
508
  logSchedule(`one-shot "${schedule.name}" deferred/skipped — re-arming for ${new Date(target).toISOString()}\n`);
463
509
  const timer = setTimeout(() => this.fireOneShot(schedule, type), delay);
464
510
  this.oneShotTimers.set(schedule.name, timer);
511
+ schedule.nextFireAt = new Date(target).toISOString();
512
+ void setNextFire(schedule.name, new Date(target)).catch(() => {});
465
513
  return;
466
514
  }
467
515
  // Mark done ONLY after a successful fire. A false/throwing fireTimed
@@ -474,6 +522,8 @@ ${Scheduler.INSTANCE_UUID}`;
474
522
  // rejects. Only a resolved dispatch counts as a real fire here.
475
523
  const fired = await this.fireTimed(schedule, type, { awaitDispatch: true });
476
524
  if (!fired) {
525
+ schedule.lastFailedAt = new Date().toISOString();
526
+ try { await markScheduleFailure(schedule.name, new Date()); } catch {}
477
527
  logSchedule(`one-shot "${schedule.name}" did not fire (skipped/guarded) — leaving pending for retry\n`);
478
528
  return;
479
529
  }
@@ -481,8 +531,12 @@ ${Scheduler.INSTANCE_UUID}`;
481
531
  schedule.lastFiredAt = now.toISOString();
482
532
  try { await markFired(schedule.name, now); }
483
533
  catch (err) { process.stderr.write(`mixdog scheduler: ${schedule.name} markFired failed: ${err}\n`); }
534
+ schedule.lastSuccessAt = new Date().toISOString();
535
+ try { await markScheduleSuccess(schedule.name, new Date()); } catch {}
484
536
  await this.markDoneWithRetry(schedule.name);
485
537
  } catch (err) {
538
+ schedule.lastFailedAt = new Date().toISOString();
539
+ try { await markScheduleFailure(schedule.name, new Date()); } catch {}
486
540
  process.stderr.write(`mixdog scheduler: ${schedule.name} one-shot failed: ${err} — leaving pending for retry\n`);
487
541
  this.notifyFailure(schedule, `one-shot failed: ${err?.message || err} — left pending for retry`);
488
542
  }
@@ -526,8 +580,11 @@ ${Scheduler.INSTANCE_UUID}`;
526
580
  name: s.name,
527
581
  time: s.whenCron ?? s.whenAt ?? null,
528
582
  type,
529
- running: false,
530
- lastFired: this.lastFired.get(s.name) ?? null,
583
+ running: this.running.has(s.name),
584
+ lastFired: this.lastFired.get(s.name) ?? s.lastFiredAt ?? null,
585
+ lastSuccess: s.lastSuccessAt ?? null,
586
+ lastFailed: s.lastFailedAt ?? null,
587
+ nextFire: s.nextFireAt ?? null,
531
588
  }));
532
589
  }
533
590
  async triggerManual(name) {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Writes <DATA_DIR>/channels/status-snapshot.json every 10 seconds so that
5
5
  * setup-server can read cross-process state (cron next-fire, deferred count,
6
- * Discord unread, relay hook URL) without IPC.
6
+ * relay hook URL) without IPC.
7
7
  *
8
8
  * Atomic write: tmp → rename so readers never see a partial file.
9
9
  *
@@ -31,60 +31,12 @@ function stableSnapshotJson(snapshot) {
31
31
  return JSON.stringify(stable, null, 2);
32
32
  }
33
33
 
34
- // ── In-memory Discord unread tracking ────────────────────────────────────────
35
- // Key: channelId Value: { label, latestSeenId, unseenCount }
36
- // No persistence across restarts — clean start is fine for v1.
37
- const _discordUnread = new Map();
38
- const DISCORD_UNREAD_MAX_CHANNELS = 500;
39
-
40
- /**
41
- * Called whenever the provider observes messages for a channelId.
42
- * `messages` is the array returned by provider.fetchMessages().
43
- * We record the most-recently-seen message id and count messages
44
- * received since the last call as "unread since last fetch".
45
- * Pass { markRead: true } for live gateway messages that were already
46
- * delivered into the bridge.
47
- */
48
- export function recordFetchedMessages(channelId, channelLabel, messages, options = {}) {
49
- if (!Array.isArray(messages) || messages.length === 0) return;
50
- const prev = _discordUnread.get(channelId);
51
- const prevLatestId = prev?.latestSeenId ?? null;
52
- const newLatestId = messages[messages.length - 1]?.id ?? null;
53
-
54
- // Count messages newer than the last seen id (BigInt-compare Discord snowflakes).
55
- let unseenCount = 0;
56
- if (options.markRead === true) {
57
- unseenCount = 0;
58
- } else if (prevLatestId) {
59
- for (const m of messages) {
60
- try {
61
- if (BigInt(m.id) > BigInt(prevLatestId)) unseenCount++;
62
- } catch { unseenCount++; }
63
- }
64
- }
65
- // First call: zero unread (baseline, not retroactive).
66
-
67
- // Retain the most recently observed channels only. Channel ids originate from
68
- // inbound traffic and ad-hoc fetches, so an unbounded process-wide registry
69
- // would otherwise grow for the worker lifetime.
70
- if (_discordUnread.has(channelId)) {
71
- _discordUnread.delete(channelId);
72
- } else if (_discordUnread.size >= DISCORD_UNREAD_MAX_CHANNELS) {
73
- _discordUnread.delete(_discordUnread.keys().next().value);
74
- }
75
- _discordUnread.set(channelId, {
76
- label: channelLabel ?? channelId,
77
- latestSeenId: newLatestId,
78
- unseenCount,
79
- });
80
- }
81
-
82
34
  // ── Snapshot computation ─────────────────────────────────────────────────────
83
35
  // The legacy HH:MM / everyNm / hourly next-fire fallback was removed: the
84
36
  // scheduler accepts cron expressions exclusively (scheduler.mjs:68), so the
85
37
  // fallback could only produce stale next-fire timestamps for entries that
86
38
  // never actually fire under the cron-only scheduler.
87
- async function computeSnapshot(scheduler) {
39
+ export async function computeSnapshot(scheduler) {
88
40
  const now = Date.now();
89
41
 
90
42
  // ── Schedules ──────────────────────────────────────────────────────────────
@@ -97,9 +49,10 @@ async function computeSnapshot(scheduler) {
97
49
  for (const [name, task] of scheduler.cronJobs) {
98
50
  if (scheduler.shouldSkip && scheduler.shouldSkip(name)) continue;
99
51
  try {
100
- // node-cron ScheduledTask exposes nextDate() / getNextDate()
101
- // depending on the installed version; try both.
52
+ // node-cron v4 exposes getNextRun(); retain older aliases for
53
+ // compatibility with persisted installations on an earlier runtime.
102
54
  const nd =
55
+ (typeof task.getNextRun === 'function' ? task.getNextRun() : null) ??
103
56
  (typeof task.nextDate === 'function' ? task.nextDate() : null) ??
104
57
  (typeof task.getNextDate === 'function' ? task.getNextDate() : null);
105
58
  if (!nd) continue;
@@ -136,18 +89,6 @@ async function computeSnapshot(scheduler) {
136
89
  }
137
90
  }
138
91
 
139
- // ── Discord unread ─────────────────────────────────────────────────────────
140
- const unreadList = [];
141
- let totalUnread = 0;
142
- for (const [channelId, entry] of _discordUnread) {
143
- unreadList.push({
144
- channelId,
145
- channelLabel: entry.label,
146
- count: entry.unseenCount,
147
- });
148
- totalUnread += entry.unseenCount;
149
- }
150
-
151
92
  // ── Relay hook URL (identity file read; assigned on first tunnel start) ────
152
93
  const hookPublicUrl = readHookPublicBase();
153
94
 
@@ -160,10 +101,6 @@ async function computeSnapshot(scheduler) {
160
101
  deferred,
161
102
  deferredCount: deferred.length,
162
103
  },
163
- discord: {
164
- unread: unreadList,
165
- totalUnread,
166
- },
167
104
  hook: {
168
105
  publicUrl: hookPublicUrl,
169
106
  },