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.
Files changed (45) 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 +5 -0
  4. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +20 -2
  5. package/src/runtime/channels/lib/config.mjs +13 -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/status-snapshot.mjs +6 -30
  9. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  10. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
  11. package/src/runtime/channels/lib/webhook.mjs +46 -246
  12. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  13. package/src/runtime/shared/automation-attachments.mjs +72 -0
  14. package/src/runtime/shared/automation-workflow.mjs +41 -0
  15. package/src/runtime/shared/config.mjs +0 -9
  16. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  17. package/src/runtime/shared/schedules-db.mjs +18 -3
  18. package/src/runtime/shared/time-format.mjs +56 -0
  19. package/src/runtime/shared/tool-card-model.mjs +740 -0
  20. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  21. package/src/runtime/shared/webhooks-db.mjs +19 -3
  22. package/src/session-runtime/channel-config-api.mjs +13 -1
  23. package/src/session-runtime/lifecycle-api.mjs +12 -0
  24. package/src/session-runtime/prewarm.mjs +13 -7
  25. package/src/session-runtime/runtime-core.mjs +127 -22
  26. package/src/session-runtime/session-text.mjs +6 -0
  27. package/src/session-runtime/settings-api.mjs +0 -12
  28. package/src/standalone/channel-admin.mjs +71 -22
  29. package/src/standalone/channel-daemon-client.mjs +5 -1
  30. package/src/standalone/channel-daemon-transport.mjs +112 -20
  31. package/src/standalone/channel-daemon.mjs +6 -4
  32. package/src/tui/App.jsx +2 -47
  33. package/src/tui/app/channel-pickers.mjs +8 -173
  34. package/src/tui/app/doctor.mjs +0 -1
  35. package/src/tui/app/slash-commands.mjs +1 -3
  36. package/src/tui/app/slash-dispatch.mjs +3 -3
  37. package/src/tui/components/ToolExecution.jsx +47 -196
  38. package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
  39. package/src/tui/components/tool-execution/text-format.mjs +27 -104
  40. package/src/tui/dist/index.mjs +340 -346
  41. package/src/tui/engine/live-share.mjs +9 -0
  42. package/src/tui/engine/session-api-ext.mjs +32 -16
  43. package/src/tui/engine.mjs +14 -1
  44. package/src/tui/time-format.mjs +4 -51
  45. 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: channel ? 'channel' : 'session',
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
- const secretValue = String(secret || randomBytes(24).toString('hex')).trim();
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
- authenticated: Boolean(webhookAuth),
550
- stored: hasStoredSecret(SECRET_ACCOUNTS.webhookAuth),
551
- status: webhookAuth ? 'Set' : 'Off',
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'} · auth ${setup.webhook.status} · port ${setup.webhook.port || 3333}`);
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
- body: { leadPid, cwd },
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) { 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
@@ -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
- if (channelPrompt.kind === 'schedule-add') {
1917
- const [name, time, instructions, channel, model] = parts;
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;