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.
- package/package.json +1 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/channels/lib/config.mjs +6 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
- package/src/runtime/channels/lib/scheduler.mjs +7 -15
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook.mjs +23 -45
- package/src/runtime/channels/lib/worker-main.mjs +45 -92
- package/src/runtime/shared/automation-attachments.mjs +72 -0
- package/src/runtime/shared/automation-workflow.mjs +41 -0
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/webhook-session-run.mjs +57 -0
- package/src/runtime/shared/webhooks-db.mjs +19 -3
- package/src/session-runtime/channel-config-api.mjs +8 -1
- package/src/session-runtime/lifecycle-api.mjs +7 -0
- package/src/session-runtime/runtime-core.mjs +103 -17
- package/src/standalone/channel-admin.mjs +36 -1
- package/src/standalone/channel-daemon-client.mjs +5 -1
- package/src/standalone/channel-daemon-transport.mjs +112 -20
- package/src/standalone/channel-daemon.mjs +6 -4
- package/src/tui/App.jsx +2 -32
- package/src/tui/app/channel-pickers.mjs +2 -143
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/dist/index.mjs +42 -169
- package/src/tui/engine/session-api-ext.mjs +32 -6
- 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) {
|
|
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 —
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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 (
|
|
569
|
-
|
|
570
|
-
|
|
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 =
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 (
|
|
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
|
|
191
|
-
// the
|
|
192
|
-
//
|
|
193
|
-
|
|
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
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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');
|