mixdog 0.9.65 → 0.9.67

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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/app.mjs +2 -2
  3. package/src/cli.mjs +1 -1
  4. package/src/repl.mjs +72 -6
  5. package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
  6. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
  7. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
  8. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
  9. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +83 -18
  10. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +14 -0
  11. package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
  12. package/src/runtime/channels/lib/config.mjs +7 -0
  13. package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
  14. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
  15. package/src/runtime/channels/lib/webhook.mjs +23 -201
  16. package/src/runtime/shared/config.mjs +0 -9
  17. package/src/runtime/shared/time-format.mjs +56 -0
  18. package/src/runtime/shared/tool-card-model.mjs +740 -0
  19. package/src/session-runtime/channel-config-api.mjs +5 -0
  20. package/src/session-runtime/context-status.mjs +2 -1
  21. package/src/session-runtime/lifecycle-api.mjs +5 -0
  22. package/src/session-runtime/prewarm.mjs +13 -7
  23. package/src/session-runtime/provider-request-tools.mjs +6 -0
  24. package/src/session-runtime/runtime-core.mjs +28 -9
  25. package/src/session-runtime/session-text.mjs +6 -0
  26. package/src/session-runtime/settings-api.mjs +0 -12
  27. package/src/session-runtime/tool-catalog-schema.mjs +5 -1
  28. package/src/standalone/channel-admin.mjs +35 -21
  29. package/src/tui/App.jsx +0 -15
  30. package/src/tui/app/channel-pickers.mjs +18 -42
  31. package/src/tui/app/doctor.mjs +0 -1
  32. package/src/tui/app/transcript-window.mjs +16 -0
  33. package/src/tui/app/use-transcript-window.mjs +36 -1
  34. package/src/tui/components/Markdown.jsx +15 -1
  35. package/src/tui/components/Message.jsx +1 -1
  36. package/src/tui/components/PromptInput.jsx +7 -0
  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 +613 -264
  41. package/src/tui/engine/frame-batched-store.mjs +5 -3
  42. package/src/tui/engine/live-share.mjs +9 -0
  43. package/src/tui/engine/render-timing.mjs +28 -0
  44. package/src/tui/engine/session-api-ext.mjs +7 -10
  45. package/src/tui/engine/tui-steering-persist.mjs +25 -4
  46. package/src/tui/engine.mjs +9 -1
  47. package/src/tui/index.jsx +11 -3
  48. package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
  49. package/src/tui/markdown/render-ansi.mjs +34 -1
  50. package/src/tui/markdown/stream-fence.mjs +44 -7
  51. package/src/tui/markdown/streaming-markdown.mjs +95 -27
  52. package/src/tui/time-format.mjs +4 -51
  53. package/src/ui/stream-finalize.mjs +45 -0
  54. package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
@@ -2,6 +2,7 @@ import {
2
2
  channelSetup,
3
3
  deleteSchedule,
4
4
  deleteWebhook,
5
+ getWebhookSecret,
5
6
  setChannelAsync,
6
7
  saveSchedule,
7
8
  saveWebhook,
@@ -68,6 +69,10 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
68
69
  reloadChannelsSoon();
69
70
  return result;
70
71
  },
72
+ // Read-only secret fetch for the webhook editor; no worker reload.
73
+ async getWebhookSecret(name) {
74
+ return getWebhookSecret(name);
75
+ },
71
76
  async runScheduleNow(name) {
72
77
  const id = String(name || '').trim();
73
78
  const schedule = await getSchedule(id);
@@ -5,6 +5,7 @@ import {
5
5
  providerTokenCalibration,
6
6
  resolveSessionCompactPolicy,
7
7
  summarizeContextMessages,
8
+ summarizeContextMessagesAtRevision,
8
9
  toolSchemaSignature,
9
10
  } from '../runtime/agent/orchestrator/session/context-utils.mjs';
10
11
  import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
@@ -234,7 +235,7 @@ export function createContextStatus({
234
235
  return contextStatusCacheValue;
235
236
  }
236
237
 
237
- const messageSummary = summarizeContextMessages(messages);
238
+ const messageSummary = summarizeContextMessagesAtRevision(messages, messagesRevision);
238
239
  const toolSchemaTokens = estimateToolSchemaTokens(requestTools);
239
240
  const toolSchemaBreakdown = estimateToolSchemaBreakdown(requestTools);
240
241
  const requestReserveTokens = estimateRequestReserveTokens(requestTools);
@@ -89,6 +89,11 @@ export function createLifecycleApi(deps) {
89
89
  return {
90
90
  id: s.id,
91
91
  updatedAt: s.updatedAt,
92
+ // Conversation-activity timestamp for Recent ordering. Without this the
93
+ // desktop falls back to updatedAt, which detach/resume bookkeeping
94
+ // bumps — clicking a session reshuffled the sidebar (the row just left
95
+ // jumped to the top).
96
+ lastUsedAt: Number(s.lastUsedAt) || 0,
92
97
  cwd: s.cwd || '',
93
98
  model: s.model,
94
99
  provider: s.provider,
@@ -15,6 +15,7 @@ export function createPrewarmSchedulers({
15
15
  getSession,
16
16
  isRemoteEnabled,
17
17
  channelsEnabled,
18
+ hasActiveAutomation,
18
19
  getCodeGraphModule,
19
20
  createCurrentSession,
20
21
  channels,
@@ -111,25 +112,30 @@ export function createPrewarmSchedulers({
111
112
  bootProfile('channels:start-skipped');
112
113
  return;
113
114
  }
114
- if (!channelsEnabled()) {
115
- bootProfile('channels:start-disabled');
116
- return;
117
- }
118
115
  if (timers.channelStartTimer || state.channelStartPromise || isCloseRequested()) return;
119
116
  bootProfile('channels:start-scheduled', { delayMs });
120
- timers.channelStartTimer = setTimeout(() => {
117
+ timers.channelStartTimer = setTimeout(() => void (async () => {
121
118
  timers.channelStartTimer = null;
122
119
  if (isCloseRequested()) return;
120
+ // Channels-module and remote toggles gate MESSAGING; automation
121
+ // (enabled schedules/webhooks) keeps the worker boot alive — its
122
+ // backend runs headless when messaging is off or unconfigured.
123
+ const automation = await hasActiveAutomation().catch(() => false);
124
+ if (!channelsEnabled() && !automation) {
125
+ bootProfile('channels:start-disabled');
126
+ return;
127
+ }
123
128
  // A deferred start may straddle a stopRemote(); re-check before booting so
124
129
  // a turned-off session neither starts channels nor keeps rescheduling.
125
- if (!isRemoteEnabled()) return;
130
+ if (!isRemoteEnabled() && !automation) return;
131
+ if (isCloseRequested()) return;
126
132
  if (getActiveTurnCount() > 0 || getSessionCreatePromise()) {
127
133
  bootProfile('channels:start-deferred', { reason: getActiveTurnCount() > 0 ? 'turn-active' : 'session-create' });
128
134
  scheduleChannelStart(backgroundBusyRetryMs);
129
135
  return;
130
136
  }
131
137
  void invokeChannelStart();
132
- }, delayMs);
138
+ })(), delayMs);
133
139
  timers.channelStartTimer.unref?.();
134
140
  }
135
141
 
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
 
3
3
  const requestToolScope = new AsyncLocalStorage();
4
4
  const NATIVE_PREFIX_COUNT = Symbol('mixdog.providerNativeToolPrefixCount');
5
+ const finalizedProviderRequestTools = new WeakSet();
5
6
  let requestToolScopeGeneration = 0;
6
7
 
7
8
  function normalizedProvider(provider) {
@@ -15,6 +16,10 @@ export function providerNativeToolPrefixCount(requestTools, fallback = 0) {
15
16
  return Math.max(0, Math.min(Array.isArray(requestTools) ? requestTools.length : 0, Number(raw) || 0));
16
17
  }
17
18
 
19
+ export function isFinalizedProviderRequestTools(requestTools) {
20
+ return Array.isArray(requestTools) && finalizedProviderRequestTools.has(requestTools);
21
+ }
22
+
18
23
  export function finalizeProviderRequestTools(requestTools, nativePrefixCount = 0) {
19
24
  Object.defineProperty(requestTools, NATIVE_PREFIX_COUNT, {
20
25
  value: Math.max(0, Math.min(requestTools.length, Number(nativePrefixCount) || 0)),
@@ -22,6 +27,7 @@ export function finalizeProviderRequestTools(requestTools, nativePrefixCount = 0
22
27
  configurable: false,
23
28
  writable: false,
24
29
  });
30
+ finalizedProviderRequestTools.add(requestTools);
25
31
  return Object.freeze(requestTools);
26
32
  }
27
33
 
@@ -65,13 +65,12 @@ import {
65
65
  deleteWebhook,
66
66
  forgetDiscordToken,
67
67
  forgetTelegramToken,
68
- forgetWebhookAuthtoken,
68
+ hasActiveAutomation,
69
69
  setChannel,
70
70
  saveDiscordToken,
71
71
  saveTelegramToken,
72
72
  saveSchedule,
73
73
  saveWebhook,
74
- saveWebhookAuthtoken,
75
74
  setBackend,
76
75
  setBackendAsync,
77
76
  setScheduleEnabled,
@@ -1694,6 +1693,7 @@ export async function createMixdogSessionRuntime({
1694
1693
  getSession: () => session,
1695
1694
  isRemoteEnabled: () => remoteEnabled,
1696
1695
  channelsEnabled,
1696
+ hasActiveAutomation,
1697
1697
  getCodeGraphModule,
1698
1698
  createCurrentSession,
1699
1699
  channels,
@@ -1884,10 +1884,6 @@ export async function createMixdogSessionRuntime({
1884
1884
  bootProfile('channels:start-skipped');
1885
1885
  return true;
1886
1886
  }
1887
- if (!channelsEnabled()) {
1888
- bootProfile('channels:start-disabled');
1889
- return true;
1890
- }
1891
1887
  if (closeRequested) return true;
1892
1888
  if (prewarmTimers.channelStartTimer) {
1893
1889
  clearTimeout(prewarmTimers.channelStartTimer);
@@ -1895,6 +1891,14 @@ export async function createMixdogSessionRuntime({
1895
1891
  }
1896
1892
  bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
1897
1893
  void (async () => {
1894
+ // Channels-module toggle gates MESSAGING only: automation (schedules/
1895
+ // webhooks) still boots the worker — its backend runs headless when
1896
+ // messaging is off or unconfigured (see channels/lib/config.mjs).
1897
+ if (!channelsEnabled() && !(await hasActiveAutomation().catch(() => false))) {
1898
+ bootProfile('channels:start-disabled');
1899
+ remoteClaimPending = false;
1900
+ return;
1901
+ }
1898
1902
  // A backend switch may still be pending or writing asynchronously. Drain
1899
1903
  // it before the worker reads config; never race it with a sync lock wait.
1900
1904
  try { await flushBackendSave(); } catch {}
@@ -2035,9 +2039,9 @@ export async function createMixdogSessionRuntime({
2035
2039
  // early /remote (startRemote clears it), stopRemote(), and close() all
2036
2040
  // cancel it through the existing clearTimeout paths. Runtime /remote calls
2037
2041
  // still start immediately (user-initiated, UI already painted).
2042
+ const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
2038
2043
  if (remoteEnabled || remoteAutoStartRequested) {
2039
2044
  const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
2040
- const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
2041
2045
  bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
2042
2046
  prewarmTimers.channelStartTimer = setTimeout(() => {
2043
2047
  prewarmTimers.channelStartTimer = null;
@@ -2048,6 +2052,23 @@ export async function createMixdogSessionRuntime({
2048
2052
  startRemote({ intent: remoteStartIntent });
2049
2053
  }, remoteAutoStartDelayMs);
2050
2054
  prewarmTimers.channelStartTimer.unref?.();
2055
+ } else {
2056
+ // Automation decoupling (user decision): enabled schedules/webhooks boot
2057
+ // the worker on their own — no remote flag, no remote.autoStart, no
2058
+ // messaging backend required. Claim-if-vacant auto intent reuses the
2059
+ // exact autoStart semantics (a live owner elsewhere wins silently).
2060
+ prewarmTimers.channelStartTimer = setTimeout(() => {
2061
+ prewarmTimers.channelStartTimer = null;
2062
+ if (closeRequested || remoteEnabled) return;
2063
+ void hasActiveAutomation()
2064
+ .then((active) => {
2065
+ if (!active || closeRequested || remoteEnabled) return;
2066
+ bootProfile('channels:automation-autostart');
2067
+ startRemote({ intent: 'auto' });
2068
+ })
2069
+ .catch(() => { /* automation probe is best-effort */ });
2070
+ }, remoteAutoStartDelayMs);
2071
+ prewarmTimers.channelStartTimer.unref?.();
2051
2072
  }
2052
2073
 
2053
2074
  // Pure settings-delegate methods (onboarding status/skip, autoClear, profile,
@@ -2103,8 +2124,6 @@ export async function createMixdogSessionRuntime({
2103
2124
  forgetDiscordToken,
2104
2125
  saveTelegramToken,
2105
2126
  forgetTelegramToken,
2106
- saveWebhookAuthtoken,
2107
- forgetWebhookAuthtoken,
2108
2127
  setBackend,
2109
2128
  });
2110
2129
 
@@ -39,6 +39,12 @@ const SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
39
39
  /^\[mixdog-runtime\]/i,
40
40
  /^\[(?:truncated|request interrupted by user)\]$/i,
41
41
  /^a previous model worked on this task and produced the compacted handoff summary below\b/i,
42
+ // Compact/auto-clear re-seed handoff variants: these lead the FIRST user
43
+ // message of every post-compaction session, so titling from them painted
44
+ // "Re-attached after compaction…" rows in Recent (user report). Skipping
45
+ // them titles the session from its first REAL user message instead.
46
+ /^re-attached after compaction\b/i,
47
+ /^reference files:\s/i,
42
48
  /^the async (?:agent|shell) task\b/i,
43
49
  ]);
44
50
 
@@ -56,8 +56,6 @@ export function createSettingsApi({
56
56
  forgetDiscordToken,
57
57
  saveTelegramToken,
58
58
  forgetTelegramToken,
59
- saveWebhookAuthtoken,
60
- forgetWebhookAuthtoken,
61
59
  setBackend,
62
60
  }) {
63
61
  return {
@@ -378,15 +376,5 @@ export function createSettingsApi({
378
376
  scheduleBackendSave(value);
379
377
  return { ok: true, backend: value };
380
378
  },
381
- saveWebhookAuthtoken(token) {
382
- const result = saveWebhookAuthtoken(token);
383
- reloadChannelsSoon();
384
- return result;
385
- },
386
- forgetWebhookAuthtoken() {
387
- const result = forgetWebhookAuthtoken();
388
- reloadChannelsSoon();
389
- return result;
390
- },
391
379
  };
392
380
  }
@@ -13,6 +13,7 @@ import {
13
13
  } from '../runtime/agent/orchestrator/providers/custom-tool-wire.mjs';
14
14
  import {
15
15
  finalizeProviderRequestTools,
16
+ isFinalizedProviderRequestTools,
16
17
  providerNativeToolPrefixCount,
17
18
  } from './provider-request-tools.mjs';
18
19
  import {
@@ -101,7 +102,10 @@ export function toolSchemaBucket(tool) {
101
102
  export function estimateToolSchemaBreakdown(tools) {
102
103
  if (Array.isArray(tools)) {
103
104
  const cached = toolSchemaBreakdownMemo.get(tools);
104
- if (sameToolSchemaEntries(cached, tools)) return cached.value;
105
+ if (cached && (
106
+ isFinalizedProviderRequestTools(tools)
107
+ || sameToolSchemaEntries(cached, tools)
108
+ )) return cached.value;
105
109
  }
106
110
  const out = {};
107
111
  const list = Array.isArray(tools) ? tools : [];
@@ -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,12 @@ 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 { readHookPublicBase } from '../runtime/channels/lib/webhook/relay-tunnel.mjs';
41
42
 
42
43
  const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
43
44
  const DEFAULT_CHANNELS = Object.freeze({
@@ -197,18 +198,6 @@ export function forgetTelegramToken() {
197
198
  return { ok: true };
198
199
  }
199
200
 
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
201
  // Single-channel write: persists `cfg.channel`, keeping the per-backend id
213
202
  // fields (discordChannelId/telegramChatId) so a backend switch retains both
214
203
  // ids. `backend` selects which per-backend field the id updates; when omitted
@@ -254,7 +243,6 @@ export function setWebhookConfig(patch = {}) {
254
243
  ...(Object.prototype.hasOwnProperty.call(patch, 'enabled') ? { enabled: patch.enabled === true } : {}),
255
244
  ...(patch.port ? { port: Number(patch.port) || 3333 } : {}),
256
245
  ...(patch.domain ? { domain: String(patch.domain).trim() } : {}),
257
- ...(patch.ngrokDomain ? { ngrokDomain: String(patch.ngrokDomain).trim() } : {}),
258
246
  },
259
247
  }));
260
248
  }
@@ -267,7 +255,6 @@ export async function setWebhookConfigAsync(patch = {}) {
267
255
  ...(Object.prototype.hasOwnProperty.call(patch, 'enabled') ? { enabled: patch.enabled === true } : {}),
268
256
  ...(patch.port ? { port: Number(patch.port) || 3333 } : {}),
269
257
  ...(patch.domain ? { domain: String(patch.domain).trim() } : {}),
270
- ...(patch.ngrokDomain ? { ngrokDomain: String(patch.ngrokDomain).trim() } : {}),
271
258
  },
272
259
  }));
273
260
  }
@@ -486,7 +473,13 @@ export async function saveWebhook({
486
473
  if (overwrite !== true && (await dbLoadEndpoint(id))) {
487
474
  throw new Error(`webhook "${id}" already exists`);
488
475
  }
489
- const secretValue = String(secret || randomBytes(24).toString('hex')).trim();
476
+ // Secret semantics: an explicit value always wins; an EMPTY value on an
477
+ // overwrite PRESERVES the stored secret (editing instructions must not
478
+ // silently rotate the key the external service was configured with); only
479
+ // a brand-new endpoint mints a random secret.
480
+ const secretValue = String(secret || '').trim()
481
+ || (overwrite === true ? String((await dbReadEndpointSecret(id)) || '').trim() : '')
482
+ || randomBytes(24).toString('hex');
490
483
  const saved = await dbUpsertEndpoint({
491
484
  name: id,
492
485
  description: String(description || '').trim(),
@@ -522,11 +515,32 @@ export async function setWebhookEnabled(name, enabled) {
522
515
  return { name: id, enabled: enabled !== false };
523
516
  }
524
517
 
518
+ // Explicit single-purpose secret read for the editor's copy affordance (the
519
+ // list path only ever exposes a presence flag). Local surfaces only — the
520
+ // desktop blocks this capability over the remote bridge.
521
+ export async function getWebhookSecret(name) {
522
+ const id = assertName(name, 'webhook name');
523
+ return { name: id, secret: (await dbReadEndpointSecret(id)) || '' };
524
+ }
525
+
526
+ // Automation presence: any enabled schedule or webhook endpoint. Drives the
527
+ // worker boot decision independently of the messaging channels — schedules
528
+ // and webhooks run sessions, so they must not require Discord/Telegram
529
+ // tokens or an explicit remote toggle.
530
+ export async function hasActiveAutomation() {
531
+ try {
532
+ const [schedules, webhooks] = await Promise.all([listSchedules(), listWebhooks()]);
533
+ return schedules.some((entry) => entry?.enabled !== false && entry?.status !== 'done')
534
+ || webhooks.some((entry) => entry?.enabled !== false);
535
+ } catch {
536
+ return false;
537
+ }
538
+ }
539
+
525
540
  export async function channelSetup(config = null) {
526
541
  const cfg = normalizeChannelsConfig(config || readSection('channels'));
527
542
  const discordToken = getDiscordToken();
528
543
  const discordProblem = diagnoseDiscordTokenValue(discordToken, cfg);
529
- const webhookAuth = getWebhookAuthtoken();
530
544
  const telegramToken = getTelegramToken();
531
545
  return {
532
546
  backend: cfg.backend || 'discord',
@@ -546,9 +560,9 @@ export async function channelSetup(config = null) {
546
560
  },
547
561
  webhook: {
548
562
  ...(cfg.webhook || {}),
549
- authenticated: Boolean(webhookAuth),
550
- stored: hasStoredSecret(SECRET_ACCOUNTS.webhookAuth),
551
- status: webhookAuth ? 'Set' : 'Off',
563
+ // Relay-tunnel public base (null until the channel worker first
564
+ // connects and mints its hook identity).
565
+ publicUrl: readHookPublicBase(),
552
566
  },
553
567
  channel: getChannel(cfg),
554
568
  schedules: await listSchedules(),
@@ -560,7 +574,7 @@ async function renderChannelStatus(config = null) {
560
574
  const setup = await channelSetup(config);
561
575
  const lines = [];
562
576
  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}`);
577
+ lines.push(`webhook ${setup.webhook.enabled === false ? 'disabled' : 'enabled'} · port ${setup.webhook.port || 3333}${setup.webhook.publicUrl ? ` · ${setup.webhook.publicUrl}` : ''}`);
564
578
  lines.push(`channel ${setup.channel.channelId || '(unset)'}`);
565
579
  lines.push('schedules');
566
580
  if (setup.schedules.length === 0) lines.push(' (none)');
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
@@ -271,46 +271,30 @@ export function createChannelPickers({
271
271
  const returnTo = typeof options.returnTo === 'function'
272
272
  ? options.returnTo
273
273
  : () => setPicker(null);
274
- const domain = setup.webhook?.ngrokDomain || setup.webhook?.domain || '';
274
+ const publicUrl = setup.webhook?.publicUrl || '';
275
275
  const items = [
276
276
  {
277
- value: 'endpoint-domain',
278
- label: 'ngrok domain',
279
- description: domain ? domain : 'Not set · Enter ngrok domain',
280
- _action: 'endpoint-domain',
281
- },
282
- {
283
- value: 'endpoint-authtoken',
284
- label: 'authtoken',
285
- description: setup.webhook?.authenticated === true ? 'Set' : 'Not set · Enter authtoken',
286
- _action: 'endpoint-authtoken',
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',
287
283
  },
288
284
  ];
289
285
  setPicker({
290
286
  title: 'Webhook endpoint',
291
- description: 'ngrok domain and authtoken. Toggle individual webhooks in /webhooks.',
287
+ description: 'Served through the Mixdog relay — no tunnel setup needed. Toggle individual webhooks in /webhooks.',
292
288
  help: '↑/↓ Select · Enter Edit · Esc Back',
293
289
  indexMode: 'always',
294
290
  labelWidth: 18,
295
291
  items,
296
292
  onSelect: (_value, item) => {
297
293
  try {
298
- if (item._action === 'endpoint-domain') {
299
- openChannelPrompt({
300
- kind: 'webhook-domain',
301
- label: 'ngrok domain',
302
- hint: 'Paste the reserved ngrok domain (e.g. my-app.ngrok-free.app).',
303
- afterSave: () => void openChannelSetupPicker('webhook-endpoint', options),
304
- });
305
- return;
306
- }
307
- if (item._action === 'endpoint-authtoken') {
308
- openChannelPrompt({
309
- kind: 'webhook-token',
310
- label: 'Webhook/ngrok authtoken',
311
- hint: 'Paste the webhook/ngrok authtoken. It is stored in the OS keychain.',
312
- afterSave: () => void openChannelSetupPicker('webhook-endpoint', options),
313
- });
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');
314
298
  }
315
299
  } catch (e) {
316
300
  store.pushNotice(`webhook endpoint failed: ${e?.message || e}`, 'error');
@@ -465,20 +449,12 @@ export function createChannelPickers({
465
449
  {
466
450
  value: 'webhook-endpoint',
467
451
  label: 'Webhook endpoint',
468
- meta: (() => {
469
- const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
470
- const hasAuth = setup.webhook?.authenticated === true;
471
- return (hasDomain && hasAuth) ? 'On' : 'Off';
472
- })(),
473
- description: (() => {
474
- const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
475
- const hasAuth = setup.webhook?.authenticated === true;
476
- const needs = [
477
- ...(hasDomain ? [] : ['domain']),
478
- ...(hasAuth ? [] : ['authtoken']),
479
- ];
480
- return needs.length ? `Needs ${needs.join(' + ')}` : 'ngrok domain and authtoken set';
481
- })(),
452
+ // Relay tunnel: public exposure is automatic, so the endpoint is
453
+ // "On" whenever the webhook server itself is enabled.
454
+ meta: setup.webhook?.enabled === false ? 'Off' : 'On',
455
+ description: setup.webhook?.publicUrl
456
+ ? setup.webhook.publicUrl
457
+ : 'Mixdog relay tunnel — URL assigned on first run',
482
458
  _action: 'webhook-endpoint',
483
459
  },
484
460
  ];
@@ -165,7 +165,6 @@ export async function buildDoctorReport(runtime = {}, getState = () => ({})) {
165
165
  const tokens = [];
166
166
  if (setup.discord?.authenticated) tokens.push('discord');
167
167
  if (setup.telegram?.authenticated) tokens.push('telegram');
168
- if (setup.webhook?.authenticated) tokens.push('webhook');
169
168
  const running = worker.running === true;
170
169
  const detail = `enabled · worker ${running ? 'running' : 'stopped'} · tokens: ${tokens.length ? tokens.join(', ') : 'none'}`;
171
170
  row(running ? 'ok' : 'warn', detail);
@@ -303,6 +303,22 @@ export function hasStreamingRowStateToPrune() {
303
303
  || streamingTailEstimateById.size > 0;
304
304
  }
305
305
 
306
+ export function transcriptHarvestInputsEqual(left, right) {
307
+ return !!left && !!right
308
+ && left.revision === right.revision
309
+ && left.settledItems === right.settledItems
310
+ && left.streamingTailItem === right.streamingTailItem
311
+ && left.startIndex === right.startIndex
312
+ && left.endIndex === right.endIndex
313
+ && left.frameColumns === right.frameColumns
314
+ && left.toolOutputExpanded === right.toolOutputExpanded
315
+ && left.transcriptContentHeight === right.transcriptContentHeight
316
+ && left.floatingPanelRows === right.floatingPanelRows
317
+ && left.overlayHintRequested === right.overlayHintRequested
318
+ && left.transcriptGuardRows === right.transcriptGuardRows
319
+ && left.themeEpoch === right.themeEpoch;
320
+ }
321
+
306
322
  /** Drop streamingMeasuredRowsById entries for ids no longer mounted, so the
307
323
  * store does not grow unbounded over a long session (mirrors the id→item /
308
324
  * id→callback map pruning already done for the mounted set). */
@@ -29,6 +29,7 @@ import {
29
29
  TRANSCRIPT_WINDOW_OVERSCAN_ROWS,
30
30
  upperBound,
31
31
  shiftSelectionRectY,
32
+ transcriptHarvestInputsEqual,
32
33
  } from './transcript-window.mjs';
33
34
  import { shouldSuppressFullyFailedToolItem } from '../transcript-tool-failures.mjs';
34
35
 
@@ -125,6 +126,11 @@ export function useTranscriptWindow({
125
126
  // overscan band).
126
127
  const transcriptItemElsRef = useRef(new Map());
127
128
  const transcriptMeasureRefCache = useRef(new Map());
129
+ const harvestGateRef = useRef({
130
+ inputs: null,
131
+ skippedForDrag: false,
132
+ forceNext: false,
133
+ });
128
134
  // id → latest item object for this render. The callback ref reads from here so
129
135
  // a reused (stable) callback never captures a stale item across patches.
130
136
  const transcriptMeasureItemsRef = useRef(new Map());
@@ -516,6 +522,20 @@ export function useTranscriptWindow({
516
522
  && floatingPanelRows <= 0
517
523
  && transcriptGuardRows > 0
518
524
  && !overlayHintOnLastItem;
525
+ const harvestInputs = {
526
+ revision,
527
+ settledItems,
528
+ streamingTailItem,
529
+ startIndex: transcriptWindow.startIndex,
530
+ endIndex: transcriptWindow.endIndex,
531
+ frameColumns,
532
+ toolOutputExpanded,
533
+ transcriptContentHeight,
534
+ floatingPanelRows,
535
+ overlayHintRequested,
536
+ transcriptGuardRows,
537
+ themeEpoch,
538
+ };
519
539
  // ── App-level measured height harvest ───────────────────────────────────
520
540
  // Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
521
541
  // so each tracked item Box's getComputedHeight() is its REAL terminal height.
@@ -528,6 +548,7 @@ export function useTranscriptWindow({
528
548
  // follow path already keeps them visually stable.
529
549
  useLayoutEffect(() => {
530
550
  if (!TRANSCRIPT_MEASURED_ROWS) return;
551
+ const gate = harvestGateRef.current;
531
552
  // Skip the per-row Yoga harvest while a drag is in progress. Edge auto-
532
553
  // scroll commits setScrollOffset on every pointer motion, but the mounted
533
554
  // rows' real heights do not change during a drag — only their scroll
@@ -535,9 +556,18 @@ export function useTranscriptWindow({
535
556
  // variantKey check per row) for no height change, which is pure drag
536
557
  // overhead on a tall transcript. The cached measurements stay authoritative
537
558
  // for the row-index math; a single re-measure is forced on release below.
538
- if (dragRef.current.active) return;
559
+ if (dragRef.current.active) {
560
+ gate.skippedForDrag = true;
561
+ return;
562
+ }
563
+ if (!gate.skippedForDrag
564
+ && !gate.forceNext
565
+ && transcriptHarvestInputsEqual(gate.inputs, harvestInputs)) return;
539
566
  const els = transcriptItemElsRef.current;
540
567
  if (!els || els.size === 0) return;
568
+ gate.inputs = harvestInputs;
569
+ gate.skippedForDrag = false;
570
+ gate.forceNext = false;
541
571
  const liveItems = transcriptMeasureItemsRef.current;
542
572
  const toolExpandedFlag = toolOutputExpanded ? 1 : 0;
543
573
  let changed = false;
@@ -655,6 +685,11 @@ export function useTranscriptWindow({
655
685
  }, 0);
656
686
  if (typeof streak.timer?.unref === 'function') streak.timer.unref();
657
687
  }
688
+ // Preserve the original convergence contract: a measurement-driven
689
+ // render gets one confirmation harvest even when no external layout
690
+ // input changed. Stable rows stop there; a real oscillation continues
691
+ // until the existing circuit breaker trips.
692
+ gate.forceNext = true;
658
693
  setMeasuredRowsVersion((v) => (v + 1) % 1000000);
659
694
  }
660
695
  }
@@ -80,6 +80,14 @@ export function Markdown({ children, themeEpoch = 0, trimPartialFences = false,
80
80
  );
81
81
  }
82
82
 
83
+ const StableMarkdownChunk = React.memo(function StableMarkdownChunk({
84
+ text,
85
+ themeEpoch,
86
+ columns,
87
+ }) {
88
+ return <Markdown themeEpoch={themeEpoch} columns={columns}>{text}</Markdown>;
89
+ });
90
+
83
91
  export function StreamingMarkdown({ children, themeEpoch = 0, columns, streamKey }) {
84
92
  const parts = resolveStreamingMarkdownParts(children, streamKey);
85
93
  if (parts.plain) {
@@ -89,9 +97,15 @@ export function StreamingMarkdown({ children, themeEpoch = 0, columns, streamKey
89
97
  // identical visible text directly.
90
98
  return <Text color={theme.text} wrap="wrap">{parts.unstableForRender}</Text>;
91
99
  }
100
+ const stableChunks = parts.stableChunks?.length
101
+ ? parts.stableChunks
102
+ : parts.stablePrefix ? [parts.stablePrefix] : [];
92
103
  return (
93
104
  <Box flexDirection="column" gap={1}>
94
- {parts.stablePrefix ? <Markdown themeEpoch={themeEpoch} columns={columns}>{parts.stablePrefix}</Markdown> : null}
105
+ {stableChunks.map((text, index) => (
106
+ <StableMarkdownChunk key={`stable-${index}`} text={text}
107
+ themeEpoch={themeEpoch} columns={columns} />
108
+ ))}
95
109
  {parts.unstableSuffix
96
110
  ? <Markdown themeEpoch={themeEpoch} columns={columns} trimPartialFences>{parts.unstableForRender}</Markdown>
97
111
  : null}
@@ -50,7 +50,7 @@ export const AssistantMessage = React.memo(function AssistantMessage({
50
50
 
51
51
  const bodyWidth = assistantBodyWidth(columns);
52
52
  const renderText = streaming && streamingWindowRows > 0
53
- ? windowPlainStreamingText(text, bodyWidth, streamingWindowRows)
53
+ ? windowPlainStreamingText(text, bodyWidth, streamingWindowRows, assistantId)
54
54
  : text;
55
55
  return (
56
56
  <Box flexDirection="row" marginTop={1}>