mixdog 0.9.67 → 0.9.68

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  3. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  4. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  5. package/src/runtime/channels/lib/config.mjs +6 -5
  6. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  7. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  8. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  9. package/src/runtime/channels/lib/webhook.mjs +23 -45
  10. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  11. package/src/runtime/shared/automation-attachments.mjs +72 -0
  12. package/src/runtime/shared/automation-workflow.mjs +41 -0
  13. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  14. package/src/runtime/shared/schedules-db.mjs +18 -3
  15. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  16. package/src/runtime/shared/webhooks-db.mjs +19 -3
  17. package/src/session-runtime/channel-config-api.mjs +8 -1
  18. package/src/session-runtime/lifecycle-api.mjs +7 -0
  19. package/src/session-runtime/runtime-core.mjs +103 -17
  20. package/src/standalone/channel-admin.mjs +36 -1
  21. package/src/standalone/channel-daemon-client.mjs +5 -1
  22. package/src/standalone/channel-daemon-transport.mjs +112 -20
  23. package/src/standalone/channel-daemon.mjs +6 -4
  24. package/src/tui/App.jsx +2 -32
  25. package/src/tui/app/channel-pickers.mjs +2 -143
  26. package/src/tui/app/slash-commands.mjs +1 -3
  27. package/src/tui/app/slash-dispatch.mjs +3 -3
  28. package/src/tui/dist/index.mjs +42 -169
  29. package/src/tui/engine/session-api-ext.mjs +32 -6
  30. package/src/tui/engine.mjs +14 -1
@@ -10093,10 +10093,8 @@ var SLASH_COMMANDS = [
10093
10093
  { name: "plugins", usage: "/plugins", description: "Manage local plugin integrations" },
10094
10094
  { name: "hooks", usage: "/hooks", description: "Manage before-tool hook rules and events" },
10095
10095
  { name: "providers", usage: "/providers", description: "Manage auth, API keys, OAuth, and local endpoints" },
10096
- { name: "channels", usage: "/channels", description: "Manage Discord, channels, schedules, webhooks" },
10096
+ { name: "channels", usage: "/channels", description: "Manage Discord, Telegram, and voice" },
10097
10097
  { name: "remote", usage: "/remote", description: "Claim remote for this session (takes over from any other session)" },
10098
- { name: "schedules", usage: "/schedules", description: "Manage schedules" },
10099
- { name: "webhooks", usage: "/webhooks", description: "Manage inbound webhooks" },
10100
10098
  { name: "settings", usage: "/setting", aliases: ["setting", "config"], aliasUsage: ["settings", "config"], showAliasUsage: false, description: "Open runtime settings" },
10101
10099
  { name: "profile", usage: "/profile", description: "Set your title and response language" },
10102
10100
  { name: "update", usage: "/update", description: "Check version and update mixdog" },
@@ -17344,139 +17342,6 @@ function createChannelPickers({
17344
17342
  setContextPanel(null);
17345
17343
  setChannelPrompt(prompt);
17346
17344
  };
17347
- const channelRemoteEnabled = store.isRemoteEnabled?.() === true;
17348
- if (focus === "schedules") {
17349
- const schedules = setup.schedules || [];
17350
- const items2 = [
17351
- ...schedules.length ? schedules.map((schedule) => {
17352
- const enabled = schedule.enabled !== false;
17353
- return {
17354
- value: `schedule:${schedule.name}`,
17355
- label: schedule.name,
17356
- marker: enabled ? "\u25CF" : "\u25CB",
17357
- markerColor: channelRemoteEnabled ? enabled ? theme.success : theme.inactive : theme.inactive,
17358
- description: `${schedule.time || "(no cron)"} \xB7 ${schedule.route}${schedule.model ? ` \xB7 ${schedule.model}` : ""}${channelRemoteEnabled ? "" : " \xB7 channel off"}`,
17359
- _action: "schedule-toggle",
17360
- _name: schedule.name,
17361
- _enabled: enabled
17362
- };
17363
- }) : [{
17364
- value: "empty",
17365
- label: "No schedules",
17366
- description: "no schedules configured",
17367
- _action: "noop"
17368
- }]
17369
- ];
17370
- const toggleSchedule = async (item) => {
17371
- if (item._action !== "schedule-toggle") return;
17372
- if (!channelRemoteEnabled) {
17373
- store.pushNotice("enable channel first", "warn");
17374
- return;
17375
- }
17376
- try {
17377
- await store.setScheduleEnabled?.(item._name, !item._enabled);
17378
- void openChannelSetupPicker("schedules", { highlightValue: `schedule:${item._name}` });
17379
- } catch (e) {
17380
- store.pushNotice(`schedule toggle failed: ${e?.message || e}`, "error");
17381
- }
17382
- };
17383
- setPicker({
17384
- title: "Schedules",
17385
- description: channelRemoteEnabled ? "Enable or disable cron schedules." : "Enable channel to toggle schedules.",
17386
- initialIndex: Math.max(0, items2.findIndex((entry) => entry.value === options.highlightValue)),
17387
- items: items2,
17388
- onSelect: (_value, item) => toggleSchedule(item),
17389
- onLeft: (item) => toggleSchedule(item),
17390
- onRight: (item) => toggleSchedule(item),
17391
- onCancel: () => {
17392
- setPicker(null);
17393
- }
17394
- });
17395
- return;
17396
- }
17397
- if (focus === "webhook-endpoint") {
17398
- const returnTo = typeof options.returnTo === "function" ? options.returnTo : () => setPicker(null);
17399
- const publicUrl = setup.webhook?.publicUrl || "";
17400
- const items2 = [
17401
- {
17402
- value: "endpoint-url",
17403
- label: "Public URL",
17404
- description: publicUrl ? `${publicUrl}/webhook/<name>` : "Assigned when the channel worker connects to the relay",
17405
- _action: "endpoint-url"
17406
- }
17407
- ];
17408
- setPicker({
17409
- title: "Webhook endpoint",
17410
- description: "Served through the Mixdog relay \u2014 no tunnel setup needed. Toggle individual webhooks in /webhooks.",
17411
- help: "\u2191/\u2193 Select \xB7 Enter Edit \xB7 Esc Back",
17412
- indexMode: "always",
17413
- labelWidth: 18,
17414
- items: items2,
17415
- onSelect: (_value, item) => {
17416
- try {
17417
- if (item._action === "endpoint-url") {
17418
- store.pushNotice(publicUrl ? `Webhook base: ${publicUrl}/webhook/<name>` : "URL not assigned yet \u2014 start channels once and reopen.", "info");
17419
- }
17420
- } catch (e) {
17421
- store.pushNotice(`webhook endpoint failed: ${e?.message || e}`, "error");
17422
- }
17423
- },
17424
- onCancel: () => {
17425
- setPicker(null);
17426
- returnTo();
17427
- }
17428
- });
17429
- return;
17430
- }
17431
- if (focus === "webhooks") {
17432
- const hooks = setup.webhooks || [];
17433
- const items2 = [
17434
- ...hooks.length ? hooks.map((hook) => {
17435
- const enabled = hook.enabled !== false;
17436
- return {
17437
- value: `webhook:${hook.name}`,
17438
- label: hook.name,
17439
- marker: enabled ? "\u25CF" : "\u25CB",
17440
- markerColor: channelRemoteEnabled ? enabled ? theme.success : theme.inactive : theme.inactive,
17441
- description: `${hook.parser || "github"} \xB7 ${hook.route} \xB7 secret:${hook.secretSet ? "set" : "missing"}${channelRemoteEnabled ? "" : " \xB7 channel off"}`,
17442
- _action: "webhook-toggle",
17443
- _name: hook.name,
17444
- _enabled: enabled
17445
- };
17446
- }) : [{
17447
- value: "empty",
17448
- label: "No webhooks",
17449
- description: "no webhook endpoints configured",
17450
- _action: "noop"
17451
- }]
17452
- ];
17453
- const toggleWebhook = async (item) => {
17454
- if (item._action !== "webhook-toggle") return;
17455
- if (!channelRemoteEnabled) {
17456
- store.pushNotice("enable channel first", "warn");
17457
- return;
17458
- }
17459
- try {
17460
- await store.setWebhookEnabled?.(item._name, !item._enabled);
17461
- void openChannelSetupPicker("webhooks", { highlightValue: `webhook:${item._name}` });
17462
- } catch (e) {
17463
- store.pushNotice(`webhook toggle failed: ${e?.message || e}`, "error");
17464
- }
17465
- };
17466
- setPicker({
17467
- title: "Webhooks",
17468
- description: channelRemoteEnabled ? "Enable or disable inbound webhook endpoints." : "Enable channel to toggle webhooks.",
17469
- initialIndex: Math.max(0, items2.findIndex((entry) => entry.value === options.highlightValue)),
17470
- items: items2,
17471
- onSelect: (_value, item) => toggleWebhook(item),
17472
- onLeft: (item) => toggleWebhook(item),
17473
- onRight: (item) => toggleWebhook(item),
17474
- onCancel: () => {
17475
- setPicker(null);
17476
- }
17477
- });
17478
- return;
17479
- }
17480
17345
  const worker = store.getChannelWorkerStatus?.();
17481
17346
  const activeBackend = setup.backend === "telegram" ? "telegram" : "discord";
17482
17347
  const backendLabel = activeBackend === "telegram" ? "Telegram" : "Discord";
@@ -19574,10 +19439,8 @@ function createSlashDispatch({
19574
19439
  void openChannelSetupPicker("all");
19575
19440
  return true;
19576
19441
  case "schedules":
19577
- void openChannelSetupPicker("schedules");
19578
- return true;
19579
19442
  case "webhooks":
19580
- void openChannelSetupPicker("webhooks");
19443
+ store.pushNotice("Schedules and webhooks are managed in the Mixdog desktop app", "info");
19581
19444
  return true;
19582
19445
  case "auth":
19583
19446
  store.pushNotice("/auth moved to /providers", "info");
@@ -22830,29 +22693,6 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
22830
22693
  });
22831
22694
  return true;
22832
22695
  }
22833
- if (channelPrompt.kind === "schedule-add") {
22834
- const [name, time, instructions, channel, model] = parts;
22835
- Promise.resolve(store.saveSchedule({ name, time, instructions, channel, model })).then(() => {
22836
- setChannelPrompt(null);
22837
- void openChannelSetupPicker("schedules");
22838
- }).catch((e) => {
22839
- store.pushNotice(`schedule save failed: ${e?.message || e}`, "error");
22840
- });
22841
- return true;
22842
- }
22843
- if (channelPrompt.kind === "webhook-add") {
22844
- const [name, instructions, channel, model, parser] = parts;
22845
- Promise.resolve(store.saveWebhook({ name, instructions, channel, model, parser })).then((result) => {
22846
- if (result?.secret) {
22847
- store.pushNotice(`webhook secret for ${result.name}: ${result.secret}`, "info");
22848
- }
22849
- setChannelPrompt(null);
22850
- void openChannelSetupPicker("webhooks");
22851
- }).catch((e) => {
22852
- store.pushNotice(`webhook save failed: ${e?.message || e}`, "error");
22853
- });
22854
- return true;
22855
- }
22856
22696
  } catch (e) {
22857
22697
  store.pushNotice(`channels update failed: ${e?.message || e}`, "error");
22858
22698
  return false;
@@ -28278,15 +28118,30 @@ function createEngineApiB(bag) {
28278
28118
  set({ commandBusy: false });
28279
28119
  }
28280
28120
  },
28281
- // Toggle Discord remote mode for this session. Flips the runtime's
28282
- // remoteEnabled flag (booting/stopping the channel worker) and returns the
28283
- // NEW enabled getState() so the caller can render an ON/OFF notice.
28121
+ // Toggle channel remote mode for the CURRENT session (session-scoped,
28122
+ // user decision). Off on claims for this session; on + this session
28123
+ // owns off; on + ANOTHER session owns move the relay seat here
28124
+ // (last-wins) without restarting the worker.
28284
28125
  toggleRemote: () => {
28285
28126
  const enabled = runtime.isRemoteEnabled?.() === true;
28286
- if (enabled) runtime.stopRemote?.();
28127
+ const owner = String(runtime.getRemoteSessionId?.() || "");
28128
+ const currentId = String(getState().sessionId || "");
28129
+ const workerRunning = runtime.getChannelWorkerStatus?.()?.running === true;
28130
+ if (enabled && !workerRunning) {
28131
+ runtime.startRemote?.();
28132
+ const next2 = runtime.isRemoteEnabled?.() === true;
28133
+ set({ remoteEnabled: next2, remoteSessionId: runtime.getRemoteSessionId?.() || null });
28134
+ return next2;
28135
+ }
28136
+ if (enabled && owner && currentId && owner !== currentId) {
28137
+ runtime.claimRemoteForCurrentSession?.();
28138
+ set({ remoteEnabled: true, remoteSessionId: runtime.getRemoteSessionId?.() || null });
28139
+ return true;
28140
+ }
28141
+ if (enabled) runtime.releaseRemote?.();
28287
28142
  else runtime.startRemote?.();
28288
28143
  const next = runtime.isRemoteEnabled?.() === true;
28289
- set({ remoteEnabled: next });
28144
+ set({ remoteEnabled: next, remoteSessionId: runtime.getRemoteSessionId?.() || null });
28290
28145
  return next;
28291
28146
  },
28292
28147
  // Force-claim remote for this session (single-holder, last-wins). Always
@@ -28296,9 +28151,16 @@ function createEngineApiB(bag) {
28296
28151
  claimRemote: () => {
28297
28152
  runtime.startRemote?.();
28298
28153
  const next = runtime.isRemoteEnabled?.() === true;
28299
- set({ remoteEnabled: next });
28154
+ set({ remoteEnabled: next, remoteSessionId: runtime.getRemoteSessionId?.() || null });
28300
28155
  return next;
28301
28156
  },
28157
+ // Idempotent desired-state OFF for desktop controls. Unlike toggleRemote,
28158
+ // a delayed/replayed request can never turn remote back on.
28159
+ releaseRemote: () => {
28160
+ runtime.releaseRemote?.();
28161
+ set({ remoteEnabled: false, remoteSessionId: null });
28162
+ return false;
28163
+ },
28302
28164
  isRemoteEnabled: () => runtime.isRemoteEnabled?.() === true,
28303
28165
  getVoiceStatus: () => getVoiceStatus(),
28304
28166
  // Desktop push-to-talk dictation: accept a recorded audio payload
@@ -31163,12 +31025,23 @@ async function createEngineSession({
31163
31025
  if (typeof runtime.onRemoteStateChange === "function") {
31164
31026
  lifecycle.unsubscribeRemoteState = runtime.onRemoteStateChange(({ enabled, reason }) => {
31165
31027
  if (flags.disposed) return;
31166
- set({ remoteEnabled: enabled === true });
31028
+ set({
31029
+ remoteEnabled: enabled === true,
31030
+ remoteSessionId: runtime.getRemoteSessionId?.() || null
31031
+ });
31167
31032
  if (reason === "superseded") {
31168
31033
  pushNotice("Remote mode OFF \u2014 another session took over remote.", "warn");
31034
+ } else if (reason === "claim-failed") {
31035
+ pushNotice("Remote mode could not start. Check the channel connection and try again.", "error");
31036
+ } else if (reason === "release-failed") {
31037
+ pushNotice("Remote mode could not be stopped cleanly. The shared state will reconcile automatically.", "error");
31169
31038
  }
31170
31039
  });
31171
31040
  }
31041
+ set({
31042
+ remoteEnabled: runtime.isRemoteEnabled?.() === true,
31043
+ remoteSessionId: runtime.getRemoteSessionId?.() || null
31044
+ });
31172
31045
  const { patchToolCardResult, flushToolResults } = createToolCardResults({
31173
31046
  getState: () => state,
31174
31047
  set,
@@ -403,15 +403,34 @@ export function createEngineApiB(bag) {
403
403
  set({ commandBusy: false });
404
404
  }
405
405
  },
406
- // Toggle Discord remote mode for this session. Flips the runtime's
407
- // remoteEnabled flag (booting/stopping the channel worker) and returns the
408
- // NEW enabled getState() so the caller can render an ON/OFF notice.
406
+ // Toggle channel remote mode for the CURRENT session (session-scoped,
407
+ // user decision). Off on claims for this session; on + this session
408
+ // owns off; on + ANOTHER session owns move the relay seat here
409
+ // (last-wins) without restarting the worker.
409
410
  toggleRemote: () => {
410
411
  const enabled = runtime.isRemoteEnabled?.() === true;
411
- if (enabled) runtime.stopRemote?.();
412
+ const owner = String(runtime.getRemoteSessionId?.() || '');
413
+ const currentId = String(getState().sessionId || '');
414
+ // Stale-enabled guard: boot auto-acquire can leave remote reading ON
415
+ // while the channels worker already self-shut (no live clients). A
416
+ // toggle in that state must BOOT the worker for this session, not
417
+ // no-op into a claim or flip a dead relay "off".
418
+ const workerRunning = runtime.getChannelWorkerStatus?.()?.running === true;
419
+ if (enabled && !workerRunning) {
420
+ runtime.startRemote?.();
421
+ const next = runtime.isRemoteEnabled?.() === true;
422
+ set({ remoteEnabled: next, remoteSessionId: runtime.getRemoteSessionId?.() || null });
423
+ return next;
424
+ }
425
+ if (enabled && owner && currentId && owner !== currentId) {
426
+ runtime.claimRemoteForCurrentSession?.();
427
+ set({ remoteEnabled: true, remoteSessionId: runtime.getRemoteSessionId?.() || null });
428
+ return true;
429
+ }
430
+ if (enabled) runtime.releaseRemote?.();
412
431
  else runtime.startRemote?.();
413
432
  const next = runtime.isRemoteEnabled?.() === true;
414
- set({ remoteEnabled: next });
433
+ set({ remoteEnabled: next, remoteSessionId: runtime.getRemoteSessionId?.() || null });
415
434
  return next;
416
435
  },
417
436
  // Force-claim remote for this session (single-holder, last-wins). Always
@@ -421,9 +440,16 @@ export function createEngineApiB(bag) {
421
440
  claimRemote: () => {
422
441
  runtime.startRemote?.();
423
442
  const next = runtime.isRemoteEnabled?.() === true;
424
- set({ remoteEnabled: next });
443
+ set({ remoteEnabled: next, remoteSessionId: runtime.getRemoteSessionId?.() || null });
425
444
  return next;
426
445
  },
446
+ // Idempotent desired-state OFF for desktop controls. Unlike toggleRemote,
447
+ // a delayed/replayed request can never turn remote back on.
448
+ releaseRemote: () => {
449
+ runtime.releaseRemote?.();
450
+ set({ remoteEnabled: false, remoteSessionId: null });
451
+ return false;
452
+ },
427
453
  isRemoteEnabled: () => runtime.isRemoteEnabled?.() === true,
428
454
  getVoiceStatus: () => getVoiceStatus(),
429
455
  // Desktop push-to-talk dictation: accept a recorded audio payload
@@ -761,12 +761,25 @@ export async function createEngineSession({
761
761
  if (typeof runtime.onRemoteStateChange === 'function') {
762
762
  lifecycle.unsubscribeRemoteState = runtime.onRemoteStateChange(({ enabled, reason }) => {
763
763
  if (flags.disposed) return;
764
- set({ remoteEnabled: enabled === true });
764
+ set({
765
+ remoteEnabled: enabled === true,
766
+ remoteSessionId: runtime.getRemoteSessionId?.() || null,
767
+ });
765
768
  if (reason === 'superseded') {
766
769
  pushNotice('Remote mode OFF — another session took over remote.', 'warn');
770
+ } else if (reason === 'claim-failed') {
771
+ pushNotice('Remote mode could not start. Check the channel connection and try again.', 'error');
772
+ } else if (reason === 'release-failed') {
773
+ pushNotice('Remote mode could not be stopped cleanly. The shared state will reconcile automatically.', 'error');
767
774
  }
768
775
  });
769
776
  }
777
+ // Seed the session-scoped remote fields so the desktop header button shows
778
+ // the real state before the first toggle/notification.
779
+ set({
780
+ remoteEnabled: runtime.isRemoteEnabled?.() === true,
781
+ remoteSessionId: runtime.getRemoteSessionId?.() || null,
782
+ });
770
783
 
771
784
  const { patchToolCardResult, flushToolResults } = createToolCardResults({
772
785
  getState: () => state,