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
@@ -15,6 +15,7 @@
15
15
  import http from 'node:http';
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { rmSync } from 'node:fs';
18
+ import { basename, dirname, join } from 'node:path';
18
19
  import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
19
20
  import { readBody, sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
20
21
 
@@ -47,9 +48,12 @@ export function createChannelDaemonTransport({
47
48
  getStatus = () => ({}),
48
49
  dispatchBind = null,
49
50
  registrationReplayTtlMs = 60_000,
51
+ remoteStatePath = null,
50
52
  } = {}) {
51
53
  if (typeof handleCall !== 'function') throw new Error('handleCall is required');
52
54
 
55
+ const resolvedRemoteStatePath = remoteStatePath
56
+ || (discoveryPath ? join(dirname(discoveryPath), 'channel-remote-state.json') : null);
53
57
  // token -> { token, leadPid, cwd, sse, lastSeen, registeredAt }
54
58
  const clients = new Map();
55
59
  // leadPid of the last client to claim the bridge / repoint the transcript.
@@ -68,6 +72,8 @@ export function createChannelDaemonTransport({
68
72
  // (notifications/claude/channel) stay pointer-targeted, never broadcast.
69
73
  const REMOTE_STATE_METHOD = 'notifications/mixdog/remote';
70
74
  let stickyRemoteFrame = null;
75
+ let remoteAcquired = false;
76
+ let remoteStateSignature = '';
71
77
  // Idempotency cache: callId -> { promise }. A retried /call with the SAME
72
78
  // callId awaits/returns the ORIGINAL run's result, so a transport-failure
73
79
  // retry never double-runs a non-idempotent tool (e.g. reply). Short TTL.
@@ -86,6 +92,44 @@ export function createChannelDaemonTransport({
86
92
 
87
93
  function nowMs() { return Date.now(); }
88
94
 
95
+ function remoteSessionIdFromBinding(name, args) {
96
+ if (!POINTER_TOOLS.has(name) || !args || typeof args !== 'object') return null;
97
+ const explicit = String(args.sessionId || '').trim();
98
+ if (/^[A-Za-z0-9_-]+$/.test(explicit)) return explicit;
99
+ const transcriptPath = String(args.transcriptPath || '').trim();
100
+ if (!transcriptPath) return null;
101
+ const inferred = basename(transcriptPath).replace(/\.[^.]+$/, '');
102
+ return /^[A-Za-z0-9_-]+$/.test(inferred) ? inferred : null;
103
+ }
104
+
105
+ function publishRemoteOwnerState() {
106
+ if (!resolvedRemoteStatePath) return;
107
+ const owner = pointerToken ? clients.get(pointerToken) : null;
108
+ const sessionId = String(owner?.remoteSessionId || '');
109
+ const state = {
110
+ enabled: remoteAcquired === true && Boolean(owner) && Boolean(sessionId),
111
+ sessionId: remoteAcquired === true && owner && sessionId ? sessionId : null,
112
+ ownerLeadPid: owner?.leadPid ?? null,
113
+ cwd: owner?.cwd ?? null,
114
+ daemonPid: process.pid,
115
+ updatedAt: nowMs(),
116
+ };
117
+ const signature = JSON.stringify([
118
+ state.enabled,
119
+ state.sessionId,
120
+ state.ownerLeadPid,
121
+ state.cwd,
122
+ state.daemonPid,
123
+ ]);
124
+ if (signature === remoteStateSignature) return;
125
+ remoteStateSignature = signature;
126
+ try {
127
+ writeJsonAtomicSync(resolvedRemoteStatePath, state, { compact: true });
128
+ } catch (err) {
129
+ log(`remote owner state write failed: ${err?.message || err}`);
130
+ }
131
+ }
132
+
89
133
  function pruneDeadClients() {
90
134
  for (const [token, c] of clients) {
91
135
  // A client is dead when its lead pid is gone OR its SSE stream closed and
@@ -117,6 +161,7 @@ export function createChannelDaemonTransport({
117
161
  // during stop()/close (grace shutdown owns teardown) and when no live client
118
162
  // remains (grace shutdown path).
119
163
  if (wasPointer && !closed) failoverPointer(reason);
164
+ publishRemoteOwnerState();
120
165
  maybeArmGrace('client removed');
121
166
  }
122
167
 
@@ -187,6 +232,7 @@ export function createChannelDaemonTransport({
187
232
  if (!best || c.lastSeen > best.lastSeen) best = c;
188
233
  }
189
234
  if (!best) return; // no live clients — let grace shutdown run
235
+ remoteAcquired = true;
190
236
  movePointer(best.token, 'failover');
191
237
  // Persist the acquired badge as the sticky frame so a later attachSse (or a
192
238
  // reconnect that follows the pointer) can replay it — matters when the
@@ -296,11 +342,16 @@ export function createChannelDaemonTransport({
296
342
  // re-binding to itself — it never "lost").
297
343
  function movePointer(newToken, reason) {
298
344
  const oldToken = pointerToken;
299
- if (oldToken === newToken) { pointerToken = newToken; return; }
345
+ if (oldToken === newToken) {
346
+ pointerToken = newToken;
347
+ publishRemoteOwnerState();
348
+ return;
349
+ }
300
350
  pointerToken = newToken;
301
351
  const oldClient = oldToken ? clients.get(oldToken) : null;
302
352
  const newClient = clients.get(newToken);
303
353
  log(`routing pointer -> token=${newToken} lead=${newClient?.leadPid ?? '?'} via ${reason}`);
354
+ publishRemoteOwnerState();
304
355
  if (!oldClient || oldClient === newClient) return;
305
356
  if (newClient && oldClient.leadPid === newClient.leadPid) return; // same TUI
306
357
  if (isPidAlive(oldClient.leadPid)) {
@@ -326,7 +377,9 @@ export function createChannelDaemonTransport({
326
377
  // a late attach that IS the pointer can replay it. Under last-wins the
327
378
  // owner is exactly the pointer client, so deliver ONLY there — a
328
379
  // broadcast would light the badge on displaced/non-owner TUIs.
380
+ remoteAcquired = true;
329
381
  stickyRemoteFrame = frame;
382
+ publishRemoteOwnerState();
330
383
  const target = resolveTarget();
331
384
  if (!target?.sse) { log('remote-state acquired not delivered (no live pointer SSE); sticky set'); return false; }
332
385
  try { target.sse.write(`data: ${frame}\n\n`); return true; }
@@ -336,7 +389,9 @@ export function createChannelDaemonTransport({
336
389
  // other transition CLEAR the sticky and broadcast to every live client —
337
390
  // whoever holds the badge must drop it; replaying it to a future attach
338
391
  // would wrongly stop a fresh remote client.
392
+ remoteAcquired = false;
339
393
  stickyRemoteFrame = null;
394
+ publishRemoteOwnerState();
340
395
  let delivered = false;
341
396
  for (const [, c] of liveClients()) {
342
397
  if (!c.sse) continue;
@@ -420,6 +475,7 @@ export function createChannelDaemonTransport({
420
475
  // A non-owner can hold a buffered superseded frame or stored bind intent.
421
476
  if (replaced.pending?.length) fresh.pending.push(...replaced.pending.splice(0));
422
477
  if (replaced.lastBind) fresh.lastBind = replaced.lastBind;
478
+ if (replaced.remoteSessionId) fresh.remoteSessionId = replaced.remoteSessionId;
423
479
  if (replacedWasPointer) pointerToken = token;
424
480
  removeClientRecord(replaced.token);
425
481
  log(`client reconnect replaced token=${replaced.token} -> ${token} lead=${pid}`);
@@ -434,13 +490,14 @@ export function createChannelDaemonTransport({
434
490
  // token, migrate its buffered pending frames + last bind intent, and remove
435
491
  // the old entry WITHOUT a failover or a self-'superseded'. A live-stream
436
492
  // pointer that happens to share the pid (e.g. co-located clients) is left
437
- // alone — last-wins handles a genuine fresh register below.
493
+ // alone — a genuine fresh attach remains an observer until it binds.
438
494
  if (pointerToken && pointerToken !== token) {
439
495
  const old = clients.get(pointerToken);
440
496
  if (old && old.leadPid === pid && !old.sse) {
441
497
  const fresh = clients.get(token);
442
498
  if (old.pending?.length) fresh.pending.push(...old.pending.splice(0));
443
499
  if (old.lastBind) fresh.lastBind = old.lastBind;
500
+ if (old.remoteSessionId) fresh.remoteSessionId = old.remoteSessionId;
444
501
  removeClientRecord(pointerToken);
445
502
  pointerToken = token;
446
503
  log(`pointer follows reconnect -> token=${token} lead=${pid} (old entry dropped)`);
@@ -451,15 +508,16 @@ export function createChannelDaemonTransport({
451
508
  return rememberReplacement(token);
452
509
  }
453
510
  }
454
- if (reattach) {
455
- // SSE-reconnect re-register: a pruned client re-registers with a FRESH
456
- // token but it is NOT a new terminal it must never steal ownership.
457
- // Only adopt the pointer when none exists (avoid a notify blackhole).
458
- if (!pointerToken) pointerToken = token;
459
- } else {
460
- // Last-wins: the NEWEST registered TUI (fresh terminal) always steals the
461
- // ownership seat, and the displaced owner is told it lost (superseded).
462
- movePointer(token, 'register');
511
+ // Registration establishes transport connectivity only. Desktop engine
512
+ // prewarm, automation, and remote auto-start may all attach to an existing
513
+ // daemon without claiming its active terminal's relay seat. Only an
514
+ // explicit pointer tool (/remote claim or transcript bind) may move an
515
+ // occupied pointer and supersede its owner. Adopt a vacant pointer so the
516
+ // first client still receives daemon notifications without a blackhole.
517
+ if (!pointerToken) {
518
+ pointerToken = token;
519
+ publishRemoteOwnerState();
520
+ log(`routing pointer initialized -> token=${token} lead=${pid}`);
463
521
  }
464
522
  return rememberReplacement(token);
465
523
  }
@@ -563,11 +621,28 @@ export function createChannelDaemonTransport({
563
621
  }
564
622
  c.lastSeen = nowMs();
565
623
  const name = String(body.name || '');
624
+ const pointerCall = Boolean(c && POINTER_TOOLS.has(name));
625
+ const pointerOwner = pointerToken ? clients.get(pointerToken) : null;
626
+ const claimIfVacant = name === 'activate_channel_bridge'
627
+ && body.args?.active === true
628
+ && body.args?.claimIfVacant === true;
629
+ const claimBlocked = claimIfVacant
630
+ && pointerToken !== clientToken
631
+ && remoteAcquired === true
632
+ && Boolean(pointerOwner?.remoteSessionId);
633
+ const pointerMove = pointerCall && !claimBlocked;
634
+ const previousRemoteSessionId = c?.remoteSessionId || null;
635
+ const previousRemoteAcquired = remoteAcquired;
566
636
  // Bind-intent calls re-point routing at the caller BEFORE dispatch, so a
567
637
  // notify emitted synchronously during the call already targets it.
568
- if (c && POINTER_TOOLS.has(name)) {
569
- // Last-wins bind claim (/remote or boot). Steals the seat AND tells
570
- // the displaced owner it lost (targeted superseded).
638
+ if (pointerMove) {
639
+ const bindingSessionId = remoteSessionIdFromBinding(name, body.args);
640
+ if (bindingSessionId) c.remoteSessionId = bindingSessionId;
641
+ if (name === 'activate_channel_bridge' && body.args?.active === false) {
642
+ remoteAcquired = false;
643
+ }
644
+ // Explicit bind claims are last-wins. Auto claims reach here only
645
+ // when no live bound remote owner occupies the pointer.
571
646
  movePointer(clientToken, name);
572
647
  }
573
648
  const callId = body.callId ? String(body.callId) : null;
@@ -577,11 +652,16 @@ export function createChannelDaemonTransport({
577
652
  // side-effect) instead of dispatching handleCall a second time.
578
653
  dispatch = callCache.get(callId).promise;
579
654
  } else {
580
- dispatch = Promise.resolve().then(() => handleCall(name, body.args || {}, {
581
- clientToken,
582
- leadPid: c?.leadPid ?? null,
583
- cwd: c?.cwd ?? null,
584
- }));
655
+ dispatch = claimBlocked
656
+ ? Promise.resolve({
657
+ content: [{ type: 'text', text: 'channel bridge claim skipped: live remote owner' }],
658
+ claimSkipped: true,
659
+ })
660
+ : Promise.resolve().then(() => handleCall(name, body.args || {}, {
661
+ clientToken,
662
+ leadPid: c?.leadPid ?? null,
663
+ cwd: c?.cwd ?? null,
664
+ }));
585
665
  if (callId) {
586
666
  callCache.set(callId, { promise: dispatch, at: nowMs() });
587
667
  // Start the TTL only once the call SETTLES: an in-flight call can
@@ -598,9 +678,17 @@ export function createChannelDaemonTransport({
598
678
  const result = await dispatch;
599
679
  // Record the caller's last bind intent so a failover can rebind the
600
680
  // output forwarder to THIS client's transcript when it becomes owner.
601
- if (c && POINTER_TOOLS.has(name)) c.lastBind = { name, args: body.args || {} };
681
+ if (pointerMove) {
682
+ c.lastBind = { name, args: body.args || {} };
683
+ publishRemoteOwnerState();
684
+ }
602
685
  sendJson(res, { result });
603
686
  } catch (err) {
687
+ if (pointerMove) {
688
+ c.remoteSessionId = previousRemoteSessionId;
689
+ remoteAcquired = previousRemoteAcquired;
690
+ publishRemoteOwnerState();
691
+ }
604
692
  sendJson(res, { error: err?.message || String(err) }, 200);
605
693
  }
606
694
  return;
@@ -640,6 +728,7 @@ export function createChannelDaemonTransport({
640
728
  boundPort = server.address().port;
641
729
  server.on('error', (err) => log(`server error: ${err?.message || err}`));
642
730
  writeDiscovery();
731
+ publishRemoteOwnerState();
643
732
  log(`daemon transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
644
733
  resolve({ port: boundPort, token: serverToken });
645
734
  });
@@ -651,6 +740,9 @@ export function createChannelDaemonTransport({
651
740
  cancelGrace();
652
741
  if (sweepTimer) { try { clearInterval(sweepTimer); } catch {} sweepTimer = null; }
653
742
  for (const [token] of clients) dropClient(token, 'transport stop');
743
+ remoteAcquired = false;
744
+ pointerToken = null;
745
+ publishRemoteOwnerState();
654
746
  clearRegistrationReplays();
655
747
  if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
656
748
  if (server) {
@@ -187,10 +187,12 @@ async function main() {
187
187
  }
188
188
  log(`ready port=${port} pid=${process.pid} in ${(performance.now() - startedAt).toFixed(0)}ms`);
189
189
 
190
- // Boot the owned channels runtime (Discord connect, transcript bind) AFTER
191
- // the ready signal. Fire-and-forget failure is non-fatal (the daemon still
192
- // serves tool calls and can reconnect later) and must not delay ready.
193
- void Promise.resolve().then(() => channels.start())
190
+ // Boot messaging only for an explicit/auto remote request. Automation may
191
+ // spawn the shared daemon with no remote intent; keep schedules/webhooks live
192
+ // without connecting the channel backend until a Remote claim arrives.
193
+ const remoteIntent = String(process.env.MIXDOG_REMOTE_INTENT || '');
194
+ const messaging = remoteIntent === 'explicit' || remoteIntent === 'auto';
195
+ void Promise.resolve().then(() => channels.start({ messaging }))
194
196
  .catch((e) => log(`channels.start failed (non-fatal): ${e?.message || e}`));
195
197
 
196
198
  // Fold memory startup in: eagerly ensure the memory runtime is up under the
package/src/tui/App.jsx CHANGED
@@ -1898,38 +1898,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1898
1898
  });
1899
1899
  return true;
1900
1900
  }
1901
- if (channelPrompt.kind === 'schedule-add') {
1902
- const [name, time, instructions, channel, model] = parts;
1903
- // saveSchedule is async (PG-backed). Only close the prompt / open the
1904
- // list on success; surface a failure notice and keep the prompt open
1905
- // so the input is not lost and the user can retry.
1906
- Promise.resolve(store.saveSchedule({ name, time, instructions, channel, model }))
1907
- .then(() => {
1908
- setChannelPrompt(null);
1909
- void openChannelSetupPicker('schedules');
1910
- })
1911
- .catch((e) => {
1912
- store.pushNotice(`schedule save failed: ${e?.message || e}`, 'error');
1913
- });
1914
- return true;
1915
- }
1916
- if (channelPrompt.kind === 'webhook-add') {
1917
- const [name, instructions, channel, model, parser] = parts;
1918
- // saveWebhook is async (PG-backed). Only close the prompt / open the
1919
- // list on success; surface a failure notice and keep the prompt open.
1920
- Promise.resolve(store.saveWebhook({ name, instructions, channel, model, parser }))
1921
- .then((result) => {
1922
- if (result?.secret) {
1923
- store.pushNotice(`webhook secret for ${result.name}: ${result.secret}`, 'info');
1924
- }
1925
- setChannelPrompt(null);
1926
- void openChannelSetupPicker('webhooks');
1927
- })
1928
- .catch((e) => {
1929
- store.pushNotice(`webhook save failed: ${e?.message || e}`, 'error');
1930
- });
1931
- return true;
1932
- }
1901
+ // schedule-add / webhook-add prompt kinds retired: schedules and
1902
+ // webhooks are managed in the desktop app (user decision).
1933
1903
  } catch (e) {
1934
1904
  store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
1935
1905
  return false;
@@ -215,149 +215,8 @@ export function createChannelPickers({
215
215
  setChannelPrompt(prompt);
216
216
  };
217
217
 
218
- const channelRemoteEnabled = store.isRemoteEnabled?.() === true;
219
-
220
- if (focus === 'schedules') {
221
- const schedules = setup.schedules || [];
222
- const items = [
223
- ...(schedules.length ? schedules.map((schedule) => {
224
- const enabled = schedule.enabled !== false;
225
- return {
226
- value: `schedule:${schedule.name}`,
227
- label: schedule.name,
228
- marker: enabled ? '●' : '○',
229
- markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
230
- description: `${schedule.time || '(no cron)'} · ${schedule.route}${schedule.model ? ` · ${schedule.model}` : ''}${channelRemoteEnabled ? '' : ' · channel off'}`,
231
- _action: 'schedule-toggle',
232
- _name: schedule.name,
233
- _enabled: enabled,
234
- };
235
- }) : [{
236
- value: 'empty',
237
- label: 'No schedules',
238
- description: 'no schedules configured',
239
- _action: 'noop',
240
- }]),
241
- ];
242
- const toggleSchedule = async (item) => {
243
- if (item._action !== 'schedule-toggle') return;
244
- if (!channelRemoteEnabled) {
245
- store.pushNotice('enable channel first', 'warn');
246
- return;
247
- }
248
- try {
249
- await store.setScheduleEnabled?.(item._name, !item._enabled);
250
- void openChannelSetupPicker('schedules', { highlightValue: `schedule:${item._name}` });
251
- } catch (e) {
252
- store.pushNotice(`schedule toggle failed: ${e?.message || e}`, 'error');
253
- }
254
- };
255
- setPicker({
256
- title: 'Schedules',
257
- description: channelRemoteEnabled ? 'Enable or disable cron schedules.' : 'Enable channel to toggle schedules.',
258
- initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
259
- items,
260
- onSelect: (_value, item) => toggleSchedule(item),
261
- onLeft: (item) => toggleSchedule(item),
262
- onRight: (item) => toggleSchedule(item),
263
- onCancel: () => {
264
- setPicker(null);
265
- },
266
- });
267
- return;
268
- }
269
-
270
- if (focus === 'webhook-endpoint') {
271
- const returnTo = typeof options.returnTo === 'function'
272
- ? options.returnTo
273
- : () => setPicker(null);
274
- const publicUrl = setup.webhook?.publicUrl || '';
275
- const items = [
276
- {
277
- value: 'endpoint-url',
278
- label: 'Public URL',
279
- description: publicUrl
280
- ? `${publicUrl}/webhook/<name>`
281
- : 'Assigned when the channel worker connects to the relay',
282
- _action: 'endpoint-url',
283
- },
284
- ];
285
- setPicker({
286
- title: 'Webhook endpoint',
287
- description: 'Served through the Mixdog relay — no tunnel setup needed. Toggle individual webhooks in /webhooks.',
288
- help: '↑/↓ Select · Enter Edit · Esc Back',
289
- indexMode: 'always',
290
- labelWidth: 18,
291
- items,
292
- onSelect: (_value, item) => {
293
- try {
294
- if (item._action === 'endpoint-url') {
295
- store.pushNotice(publicUrl
296
- ? `Webhook base: ${publicUrl}/webhook/<name>`
297
- : 'URL not assigned yet — start channels once and reopen.', 'info');
298
- }
299
- } catch (e) {
300
- store.pushNotice(`webhook endpoint failed: ${e?.message || e}`, 'error');
301
- }
302
- },
303
- onCancel: () => {
304
- setPicker(null);
305
- returnTo();
306
- },
307
- });
308
- return;
309
- }
310
-
311
- if (focus === 'webhooks') {
312
- const hooks = setup.webhooks || [];
313
- const items = [
314
- ...(hooks.length ? hooks.map((hook) => {
315
- const enabled = hook.enabled !== false;
316
- return {
317
- value: `webhook:${hook.name}`,
318
- label: hook.name,
319
- marker: enabled ? '●' : '○',
320
- markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
321
- description: `${hook.parser || 'github'} · ${hook.route} · secret:${hook.secretSet ? 'set' : 'missing'}${channelRemoteEnabled ? '' : ' · channel off'}`,
322
- _action: 'webhook-toggle',
323
- _name: hook.name,
324
- _enabled: enabled,
325
- };
326
- }) : [{
327
- value: 'empty',
328
- label: 'No webhooks',
329
- description: 'no webhook endpoints configured',
330
- _action: 'noop',
331
- }]),
332
- ];
333
- const toggleWebhook = async (item) => {
334
- if (item._action !== 'webhook-toggle') return;
335
- if (!channelRemoteEnabled) {
336
- store.pushNotice('enable channel first', 'warn');
337
- return;
338
- }
339
- try {
340
- await store.setWebhookEnabled?.(item._name, !item._enabled);
341
- void openChannelSetupPicker('webhooks', { highlightValue: `webhook:${item._name}` });
342
- } catch (e) {
343
- store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
344
- }
345
- };
346
- setPicker({
347
- title: 'Webhooks',
348
- description: channelRemoteEnabled ? 'Enable or disable inbound webhook endpoints.' : 'Enable channel to toggle webhooks.',
349
- initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
350
- items,
351
- onSelect: (_value, item) => toggleWebhook(item),
352
- onLeft: (item) => toggleWebhook(item),
353
- onRight: (item) => toggleWebhook(item),
354
- onCancel: () => {
355
- setPicker(null);
356
- },
357
- });
358
- return;
359
- }
360
-
218
+ // Schedules/webhooks management is desktop-only (user decision): the TUI
219
+ // no longer carries their pickers or the webhook-endpoint info page.
361
220
  const worker = store.getChannelWorkerStatus?.();
362
221
  const activeBackend = setup.backend === 'telegram' ? 'telegram' : 'discord';
363
222
  const backendLabel = activeBackend === 'telegram' ? 'Telegram' : 'Discord';
@@ -25,10 +25,8 @@ export const SLASH_COMMANDS = [
25
25
  { name: 'plugins', usage: '/plugins', description: 'Manage local plugin integrations' },
26
26
  { name: 'hooks', usage: '/hooks', description: 'Manage before-tool hook rules and events' },
27
27
  { name: 'providers', usage: '/providers', description: 'Manage auth, API keys, OAuth, and local endpoints' },
28
- { name: 'channels', usage: '/channels', description: 'Manage Discord, channels, schedules, webhooks' },
28
+ { name: 'channels', usage: '/channels', description: 'Manage Discord, Telegram, and voice' },
29
29
  { name: 'remote', usage: '/remote', description: 'Claim remote for this session (takes over from any other session)' },
30
- { name: 'schedules', usage: '/schedules', description: 'Manage schedules' },
31
- { name: 'webhooks', usage: '/webhooks', description: 'Manage inbound webhooks' },
32
30
  { name: 'settings', usage: '/setting', aliases: ['setting', 'config'], aliasUsage: ['settings', 'config'], showAliasUsage: false, description: 'Open runtime settings' },
33
31
  { name: 'profile', usage: '/profile', description: 'Set your title and response language' },
34
32
  { name: 'update', usage: '/update', description: 'Check version and update mixdog' },
@@ -265,10 +265,10 @@ export function createSlashDispatch({
265
265
  void openChannelSetupPicker('all');
266
266
  return true;
267
267
  case 'schedules':
268
- void openChannelSetupPicker('schedules');
269
- return true;
270
268
  case 'webhooks':
271
- void openChannelSetupPicker('webhooks');
269
+ // Management surface is desktop-only (user decision): hidden from the
270
+ // palette, and a typed command answers instead of opening a picker.
271
+ store.pushNotice('Schedules and webhooks are managed in the Mixdog desktop app', 'info');
272
272
  return true;
273
273
  case 'auth':
274
274
  store.pushNotice('/auth moved to /providers', 'info');