mixdog 0.9.66 → 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 +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +20 -2
- package/src/runtime/channels/lib/config.mjs +13 -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/status-snapshot.mjs +6 -30
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
- package/src/runtime/channels/lib/webhook.mjs +46 -246
- 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/config.mjs +0 -9
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/time-format.mjs +56 -0
- package/src/runtime/shared/tool-card-model.mjs +740 -0
- 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 +13 -1
- package/src/session-runtime/lifecycle-api.mjs +12 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/runtime-core.mjs +127 -22
- package/src/session-runtime/session-text.mjs +6 -0
- package/src/session-runtime/settings-api.mjs +0 -12
- package/src/standalone/channel-admin.mjs +71 -22
- 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 -47
- package/src/tui/app/channel-pickers.mjs +8 -173
- package/src/tui/app/doctor.mjs +0 -1
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/components/ToolExecution.jsx +47 -196
- package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
- package/src/tui/components/tool-execution/text-format.mjs +27 -104
- package/src/tui/dist/index.mjs +340 -346
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/session-api-ext.mjs +32 -16
- package/src/tui/engine.mjs +14 -1
- package/src/tui/time-format.mjs +4 -51
- package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
diagnoseDiscordTokenValue,
|
|
16
16
|
getDiscordToken,
|
|
17
17
|
getTelegramToken,
|
|
18
|
-
getWebhookAuthtoken,
|
|
19
18
|
hasStoredSecret,
|
|
20
19
|
readSection,
|
|
21
20
|
saveSecret,
|
|
@@ -34,10 +33,13 @@ import {
|
|
|
34
33
|
import {
|
|
35
34
|
listEndpoints as dbListEndpoints,
|
|
36
35
|
loadEndpointConfig as dbLoadEndpoint,
|
|
36
|
+
readEndpointSecret as dbReadEndpointSecret,
|
|
37
37
|
upsertEndpoint as dbUpsertEndpoint,
|
|
38
38
|
deleteEndpoint as dbDeleteEndpoint,
|
|
39
39
|
setEndpointEnabled as dbSetEndpointEnabled,
|
|
40
40
|
} from '../runtime/shared/webhooks-db.mjs';
|
|
41
|
+
import { normalizeAutomationAttachments } from '../runtime/shared/automation-attachments.mjs';
|
|
42
|
+
import { readHookPublicBase } from '../runtime/channels/lib/webhook/relay-tunnel.mjs';
|
|
41
43
|
|
|
42
44
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
43
45
|
const DEFAULT_CHANNELS = Object.freeze({
|
|
@@ -197,18 +199,6 @@ export function forgetTelegramToken() {
|
|
|
197
199
|
return { ok: true };
|
|
198
200
|
}
|
|
199
201
|
|
|
200
|
-
export function saveWebhookAuthtoken(token) {
|
|
201
|
-
const value = String(token || '').trim();
|
|
202
|
-
if (!value) throw new Error('Webhook/ngrok authtoken is required');
|
|
203
|
-
saveSecret(SECRET_ACCOUNTS.webhookAuth, value);
|
|
204
|
-
return { ok: true, configured: Boolean(getWebhookAuthtoken()) };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function forgetWebhookAuthtoken() {
|
|
208
|
-
deleteSecret(SECRET_ACCOUNTS.webhookAuth);
|
|
209
|
-
return { ok: true };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
202
|
// Single-channel write: persists `cfg.channel`, keeping the per-backend id
|
|
213
203
|
// fields (discordChannelId/telegramChatId) so a backend switch retains both
|
|
214
204
|
// ids. `backend` selects which per-backend field the id updates; when omitted
|
|
@@ -254,7 +244,6 @@ export function setWebhookConfig(patch = {}) {
|
|
|
254
244
|
...(Object.prototype.hasOwnProperty.call(patch, 'enabled') ? { enabled: patch.enabled === true } : {}),
|
|
255
245
|
...(patch.port ? { port: Number(patch.port) || 3333 } : {}),
|
|
256
246
|
...(patch.domain ? { domain: String(patch.domain).trim() } : {}),
|
|
257
|
-
...(patch.ngrokDomain ? { ngrokDomain: String(patch.ngrokDomain).trim() } : {}),
|
|
258
247
|
},
|
|
259
248
|
}));
|
|
260
249
|
}
|
|
@@ -267,7 +256,6 @@ export async function setWebhookConfigAsync(patch = {}) {
|
|
|
267
256
|
...(Object.prototype.hasOwnProperty.call(patch, 'enabled') ? { enabled: patch.enabled === true } : {}),
|
|
268
257
|
...(patch.port ? { port: Number(patch.port) || 3333 } : {}),
|
|
269
258
|
...(patch.domain ? { domain: String(patch.domain).trim() } : {}),
|
|
270
|
-
...(patch.ngrokDomain ? { ngrokDomain: String(patch.ngrokDomain).trim() } : {}),
|
|
271
259
|
},
|
|
272
260
|
}));
|
|
273
261
|
}
|
|
@@ -371,6 +359,11 @@ function scheduleToDisplay(s) {
|
|
|
371
359
|
channel: s.channelId || undefined,
|
|
372
360
|
model: s.model || undefined,
|
|
373
361
|
cwd: s.cwd || undefined,
|
|
362
|
+
workflow: s.workflow || undefined,
|
|
363
|
+
attachments: s.attachments || undefined,
|
|
364
|
+
// Delivery mode: legacy channel-target rows (pre-delivery) behaved as
|
|
365
|
+
// relay+visible, which maps to 'both'.
|
|
366
|
+
delivery: s.delivery || (s.target === 'channel' ? 'both' : 'app'),
|
|
374
367
|
enabled: s.enabled !== false,
|
|
375
368
|
instructions: s.prompt,
|
|
376
369
|
route: s.target === 'channel' ? `channel:${s.channelId}` : 'session',
|
|
@@ -396,6 +389,9 @@ export async function saveSchedule({
|
|
|
396
389
|
channel,
|
|
397
390
|
model,
|
|
398
391
|
cwd,
|
|
392
|
+
workflow,
|
|
393
|
+
attachments,
|
|
394
|
+
delivery,
|
|
399
395
|
enabled,
|
|
400
396
|
instructions,
|
|
401
397
|
overwrite = false,
|
|
@@ -404,6 +400,11 @@ export async function saveSchedule({
|
|
|
404
400
|
const body = String(instructions || '').trim();
|
|
405
401
|
if (!body) throw new Error('schedule instructions are required');
|
|
406
402
|
if (channel && !model) throw new Error('model is required when channel is set');
|
|
403
|
+
// 'app' → session-only; 'channel'/'both' → the run result relays to the
|
|
404
|
+
// main channel (target 'channel', channelId resolved at fire time).
|
|
405
|
+
const mode = ['app', 'channel', 'both'].includes(String(delivery || '').trim())
|
|
406
|
+
? String(delivery).trim()
|
|
407
|
+
: (channel ? 'both' : 'app');
|
|
407
408
|
const hasTime = time != null && String(time).trim() !== '';
|
|
408
409
|
const hasAt = at != null && String(at).trim() !== '';
|
|
409
410
|
if (hasTime && hasAt) throw new Error('provide either `time` (recurring) or `at` (one-shot), not both');
|
|
@@ -419,10 +420,13 @@ export async function saveSchedule({
|
|
|
419
420
|
whenCron,
|
|
420
421
|
whenAt,
|
|
421
422
|
timezone: timezone ? String(timezone).trim() : null,
|
|
422
|
-
target:
|
|
423
|
+
target: mode === 'app' ? 'session' : 'channel',
|
|
423
424
|
channelId: channel ? String(channel).trim() : null,
|
|
424
425
|
model: model ? String(model).trim() : null,
|
|
425
426
|
cwd: cwd ? String(cwd).trim() : null,
|
|
427
|
+
workflow: workflow ? String(workflow).trim() : null,
|
|
428
|
+
attachments: normalizeAutomationAttachments(attachments),
|
|
429
|
+
delivery: mode,
|
|
426
430
|
prompt: body,
|
|
427
431
|
enabled: enabled !== false,
|
|
428
432
|
});
|
|
@@ -454,6 +458,10 @@ async function listWebhooks() {
|
|
|
454
458
|
parser: ep.parser || 'github',
|
|
455
459
|
...(ep.channelId ? { channel: ep.channelId } : {}),
|
|
456
460
|
...(ep.model ? { model: ep.model } : {}),
|
|
461
|
+
...(ep.cwd ? { cwd: ep.cwd } : {}),
|
|
462
|
+
...(ep.workflow ? { workflow: ep.workflow } : {}),
|
|
463
|
+
...(ep.attachments ? { attachments: ep.attachments } : {}),
|
|
464
|
+
delivery: ep.delivery || 'app',
|
|
457
465
|
enabled: ep.enabled,
|
|
458
466
|
// The store never projects the plaintext secret through list paths; it
|
|
459
467
|
// exposes a presence flag (secretSet) instead.
|
|
@@ -471,6 +479,10 @@ export async function saveWebhook({
|
|
|
471
479
|
secret,
|
|
472
480
|
channel,
|
|
473
481
|
model,
|
|
482
|
+
cwd,
|
|
483
|
+
workflow,
|
|
484
|
+
attachments,
|
|
485
|
+
delivery,
|
|
474
486
|
enabled,
|
|
475
487
|
instructions,
|
|
476
488
|
overwrite = false,
|
|
@@ -486,13 +498,25 @@ export async function saveWebhook({
|
|
|
486
498
|
if (overwrite !== true && (await dbLoadEndpoint(id))) {
|
|
487
499
|
throw new Error(`webhook "${id}" already exists`);
|
|
488
500
|
}
|
|
489
|
-
|
|
501
|
+
// Secret semantics: an explicit value always wins; an EMPTY value on an
|
|
502
|
+
// overwrite PRESERVES the stored secret (editing instructions must not
|
|
503
|
+
// silently rotate the key the external service was configured with); only
|
|
504
|
+
// a brand-new endpoint mints a random secret.
|
|
505
|
+
const secretValue = String(secret || '').trim()
|
|
506
|
+
|| (overwrite === true ? String((await dbReadEndpointSecret(id)) || '').trim() : '')
|
|
507
|
+
|| randomBytes(24).toString('hex');
|
|
490
508
|
const saved = await dbUpsertEndpoint({
|
|
491
509
|
name: id,
|
|
492
510
|
description: String(description || '').trim(),
|
|
493
511
|
parser: nextParser,
|
|
494
512
|
channelId: channel ? String(channel).trim() : null,
|
|
495
513
|
model: model ? String(model).trim() : null,
|
|
514
|
+
cwd: cwd ? String(cwd).trim() : null,
|
|
515
|
+
workflow: workflow ? String(workflow).trim() : null,
|
|
516
|
+
attachments: normalizeAutomationAttachments(attachments),
|
|
517
|
+
delivery: ['app', 'channel', 'both'].includes(String(delivery || '').trim())
|
|
518
|
+
? String(delivery).trim()
|
|
519
|
+
: 'app',
|
|
496
520
|
secret: secretValue,
|
|
497
521
|
instructions: body,
|
|
498
522
|
enabled: enabled !== false,
|
|
@@ -503,6 +527,10 @@ export async function saveWebhook({
|
|
|
503
527
|
parser: saved.parser,
|
|
504
528
|
...(saved.channelId ? { channel: saved.channelId } : {}),
|
|
505
529
|
...(saved.model ? { model: saved.model } : {}),
|
|
530
|
+
...(saved.cwd ? { cwd: saved.cwd } : {}),
|
|
531
|
+
...(saved.workflow ? { workflow: saved.workflow } : {}),
|
|
532
|
+
...(saved.attachments ? { attachments: saved.attachments } : {}),
|
|
533
|
+
delivery: saved.delivery || 'app',
|
|
506
534
|
...(enabled === false ? { enabled: false } : {}),
|
|
507
535
|
secret: secretValue,
|
|
508
536
|
instructions: body,
|
|
@@ -522,11 +550,32 @@ export async function setWebhookEnabled(name, enabled) {
|
|
|
522
550
|
return { name: id, enabled: enabled !== false };
|
|
523
551
|
}
|
|
524
552
|
|
|
553
|
+
// Explicit single-purpose secret read for the editor's copy affordance (the
|
|
554
|
+
// list path only ever exposes a presence flag). Local surfaces only — the
|
|
555
|
+
// desktop blocks this capability over the remote bridge.
|
|
556
|
+
export async function getWebhookSecret(name) {
|
|
557
|
+
const id = assertName(name, 'webhook name');
|
|
558
|
+
return { name: id, secret: (await dbReadEndpointSecret(id)) || '' };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Automation presence: any enabled schedule or webhook endpoint. Drives the
|
|
562
|
+
// worker boot decision independently of the messaging channels — schedules
|
|
563
|
+
// and webhooks run sessions, so they must not require Discord/Telegram
|
|
564
|
+
// tokens or an explicit remote toggle.
|
|
565
|
+
export async function hasActiveAutomation() {
|
|
566
|
+
try {
|
|
567
|
+
const [schedules, webhooks] = await Promise.all([listSchedules(), listWebhooks()]);
|
|
568
|
+
return schedules.some((entry) => entry?.enabled !== false && entry?.status !== 'done')
|
|
569
|
+
|| webhooks.some((entry) => entry?.enabled !== false);
|
|
570
|
+
} catch {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
525
575
|
export async function channelSetup(config = null) {
|
|
526
576
|
const cfg = normalizeChannelsConfig(config || readSection('channels'));
|
|
527
577
|
const discordToken = getDiscordToken();
|
|
528
578
|
const discordProblem = diagnoseDiscordTokenValue(discordToken, cfg);
|
|
529
|
-
const webhookAuth = getWebhookAuthtoken();
|
|
530
579
|
const telegramToken = getTelegramToken();
|
|
531
580
|
return {
|
|
532
581
|
backend: cfg.backend || 'discord',
|
|
@@ -546,9 +595,9 @@ export async function channelSetup(config = null) {
|
|
|
546
595
|
},
|
|
547
596
|
webhook: {
|
|
548
597
|
...(cfg.webhook || {}),
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
598
|
+
// Relay-tunnel public base (null until the channel worker first
|
|
599
|
+
// connects and mints its hook identity).
|
|
600
|
+
publicUrl: readHookPublicBase(),
|
|
552
601
|
},
|
|
553
602
|
channel: getChannel(cfg),
|
|
554
603
|
schedules: await listSchedules(),
|
|
@@ -560,7 +609,7 @@ async function renderChannelStatus(config = null) {
|
|
|
560
609
|
const setup = await channelSetup(config);
|
|
561
610
|
const lines = [];
|
|
562
611
|
lines.push(`discord ${setup.discord.status}${setup.discord.problem ? ` (${setup.discord.problem})` : ''}`);
|
|
563
|
-
lines.push(`webhook ${setup.webhook.enabled === false ? 'disabled' : 'enabled'} ·
|
|
612
|
+
lines.push(`webhook ${setup.webhook.enabled === false ? 'disabled' : 'enabled'} · port ${setup.webhook.port || 3333}${setup.webhook.publicUrl ? ` · ${setup.webhook.publicUrl}` : ''}`);
|
|
564
613
|
lines.push(`channel ${setup.channel.channelId || '(unset)'}`);
|
|
565
614
|
lines.push('schedules');
|
|
566
615
|
if (setup.schedules.length === 0) lines.push(' (none)');
|
|
@@ -147,7 +147,11 @@ export async function attachToDaemon({
|
|
|
147
147
|
token: serverToken,
|
|
148
148
|
method: 'POST',
|
|
149
149
|
path: '/client/register',
|
|
150
|
-
|
|
150
|
+
// Initial attachment is transport-only. Mark it with the legacy
|
|
151
|
+
// non-stealing registration intent as well as relying on the current
|
|
152
|
+
// daemon contract, so a newly updated desktop cannot supersede a
|
|
153
|
+
// terminal still served by an older machine-global daemon.
|
|
154
|
+
body: { leadPid, cwd, reattach: true },
|
|
151
155
|
timeoutMs: 3000,
|
|
152
156
|
});
|
|
153
157
|
} catch (err) {
|
|
@@ -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
|
@@ -1881,21 +1881,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1881
1881
|
resumeAfterChannelPrompt(channelPrompt);
|
|
1882
1882
|
return true;
|
|
1883
1883
|
}
|
|
1884
|
-
if (channelPrompt.kind === 'webhook-token') {
|
|
1885
|
-
if (!commandText) return false;
|
|
1886
|
-
store.saveWebhookAuthtoken(commandText);
|
|
1887
|
-
resumeAfterChannelPrompt(channelPrompt);
|
|
1888
|
-
return true;
|
|
1889
|
-
}
|
|
1890
|
-
if (channelPrompt.kind === 'webhook-domain') {
|
|
1891
|
-
if (!commandText) return false;
|
|
1892
|
-
Promise.resolve(store.setWebhookConfig?.({ ngrokDomain: commandText }))
|
|
1893
|
-
.then(() => resumeAfterChannelPrompt(channelPrompt))
|
|
1894
|
-
.catch((e) => {
|
|
1895
|
-
store.pushNotice(`webhook config update failed: ${e?.message || e}`, 'error');
|
|
1896
|
-
});
|
|
1897
|
-
return true;
|
|
1898
|
-
}
|
|
1899
1884
|
const parts = commandText.split('|').map((part) => part.trim());
|
|
1900
1885
|
if (channelPrompt.kind === 'channel-add') {
|
|
1901
1886
|
// Single-channel: the UI asks only for the channel id. Legacy
|
|
@@ -1913,38 +1898,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1913
1898
|
});
|
|
1914
1899
|
return true;
|
|
1915
1900
|
}
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
// saveSchedule is async (PG-backed). Only close the prompt / open the
|
|
1919
|
-
// list on success; surface a failure notice and keep the prompt open
|
|
1920
|
-
// so the input is not lost and the user can retry.
|
|
1921
|
-
Promise.resolve(store.saveSchedule({ name, time, instructions, channel, model }))
|
|
1922
|
-
.then(() => {
|
|
1923
|
-
setChannelPrompt(null);
|
|
1924
|
-
void openChannelSetupPicker('schedules');
|
|
1925
|
-
})
|
|
1926
|
-
.catch((e) => {
|
|
1927
|
-
store.pushNotice(`schedule save failed: ${e?.message || e}`, 'error');
|
|
1928
|
-
});
|
|
1929
|
-
return true;
|
|
1930
|
-
}
|
|
1931
|
-
if (channelPrompt.kind === 'webhook-add') {
|
|
1932
|
-
const [name, instructions, channel, model, parser] = parts;
|
|
1933
|
-
// saveWebhook is async (PG-backed). Only close the prompt / open the
|
|
1934
|
-
// list on success; surface a failure notice and keep the prompt open.
|
|
1935
|
-
Promise.resolve(store.saveWebhook({ name, instructions, channel, model, parser }))
|
|
1936
|
-
.then((result) => {
|
|
1937
|
-
if (result?.secret) {
|
|
1938
|
-
store.pushNotice(`webhook secret for ${result.name}: ${result.secret}`, 'info');
|
|
1939
|
-
}
|
|
1940
|
-
setChannelPrompt(null);
|
|
1941
|
-
void openChannelSetupPicker('webhooks');
|
|
1942
|
-
})
|
|
1943
|
-
.catch((e) => {
|
|
1944
|
-
store.pushNotice(`webhook save failed: ${e?.message || e}`, 'error');
|
|
1945
|
-
});
|
|
1946
|
-
return true;
|
|
1947
|
-
}
|
|
1901
|
+
// schedule-add / webhook-add prompt kinds retired: schedules and
|
|
1902
|
+
// webhooks are managed in the desktop app (user decision).
|
|
1948
1903
|
} catch (e) {
|
|
1949
1904
|
store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
|
|
1950
1905
|
return false;
|