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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.66",
3
+ "version": "0.9.68",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -395,6 +395,9 @@ export function createSession(opts) {
395
395
  // scheduler/daily-standup, webhook/github-push, lead/worker.
396
396
  sourceType: opts.sourceType || null,
397
397
  sourceName: opts.sourceName || null,
398
+ // Automation delivery mode ('app' | 'channel' | 'both'): the desktop
399
+ // sidebar hides channel-only runner sessions from Automations.
400
+ sourceDelivery: opts.sourceDelivery || null,
398
401
  // Provider-scoped unified cache key — one shard per provider,
399
402
  // shared across all roles / sources (agent/maintenance/mcp/
400
403
  // scheduler/webhook). Role or source-specific context must be
@@ -137,6 +137,7 @@ export function _sessionSummary(session) {
137
137
  agent: session.agent || null,
138
138
  sourceType: session.sourceType || null,
139
139
  sourceName: session.sourceName || null,
140
+ sourceDelivery: session.sourceDelivery || null,
140
141
  scopeKey: session.scopeKey || null,
141
142
  ownerSessionId: session.ownerSessionId || null,
142
143
  clientHostPid: _positiveNumber(session.clientHostPid, 0) || null,
@@ -152,6 +153,9 @@ export function _sessionSummary(session) {
152
153
  preview: messageProjection.preview,
153
154
  generation: typeof session.generation === 'number' ? session.generation : 0,
154
155
  implicitBashSessionId: session.implicitBashSessionId || null,
156
+ // Lifecycle provenance for catalog filters: resume-machinery scratch
157
+ // forks (detachedReason='cli-resume') must never surface in Recent.
158
+ detachedReason: session.detachedReason || null,
155
159
  };
156
160
  }
157
161
 
@@ -184,6 +188,7 @@ function _normalizeSummaryRow(row) {
184
188
  preview: _cleanPreview(row.preview || ''),
185
189
  generation: typeof row.generation === 'number' ? row.generation : 0,
186
190
  implicitBashSessionId: row.implicitBashSessionId || null,
191
+ detachedReason: row.detachedReason || null,
187
192
  };
188
193
  }
189
194
 
@@ -31,6 +31,7 @@ function isLeadVisibleRow(row) {
31
31
  || sourceType === 'lead'
32
32
  || sourceType === 'cli'
33
33
  || sourceType === 'schedule'
34
+ || sourceType === 'webhook'
34
35
  || (!sourceType && !sourceName && owner !== 'agent');
35
36
  }
36
37
 
@@ -49,6 +50,11 @@ function cleanText(value, maximum = 240) {
49
50
  return String(value || '')
50
51
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
51
52
  .replace(/<mcp-instructions>[\s\S]*?<\/mcp-instructions>/gi, ' ')
53
+ // Session-context envelope (mirror of session-text.mjs
54
+ // stripSessionDisplayEnvelope): the "# Session / Cwd / Model /
55
+ // Workflow" header must never become a Recent title.
56
+ .replace(/^\s*# Session\r?\n(?:(?:Cwd|Model|Workflow):[^\r\n]*(?:\r?\n|$))+(?:\r?\n)?/i, ' ')
57
+ .replace(/^\s*#\s*Session\s+Cwd:\s+\S+(?:\s+Model:[^\r\n]*?)?(?:\s+Workflow:\s+\S+)?\s*/i, ' ')
52
58
  .replace(/\s+/g, ' ')
53
59
  .trim()
54
60
  .slice(0, maximum);
@@ -78,6 +84,10 @@ function normalizedRow(row, heartbeatAt = 0) {
78
84
  return {
79
85
  id: row.id,
80
86
  updatedAt: positiveNumber(row.updatedAt, 0),
87
+ // Conversation-activity timestamp (mirror of listLeadSessions):
88
+ // detach/resume bookkeeping bumps updatedAt in bulk on restarts, so
89
+ // Recent must order by lastUsedAt or every restart reshuffles rows.
90
+ lastUsedAt: positiveNumber(row.lastUsedAt, 0),
81
91
  createdAt: positiveNumber(row.createdAt, 0),
82
92
  lastHeartbeatAt: positiveNumber(row.lastHeartbeatAt, 0),
83
93
  // Liveness comes from the .hb sidecar mtime alone: stored row fields
@@ -90,6 +100,7 @@ function normalizedRow(row, heartbeatAt = 0) {
90
100
  agent: row.agent || null,
91
101
  sourceType: row.sourceType || null,
92
102
  sourceName: row.sourceName || null,
103
+ sourceDelivery: row.sourceDelivery || null,
93
104
  scopeKey: row.scopeKey || null,
94
105
  ownerSessionId: row.ownerSessionId || null,
95
106
  clientHostPid: positiveNumber(row.clientHostPid, 0) || null,
@@ -105,6 +116,7 @@ function normalizedRow(row, heartbeatAt = 0) {
105
116
  preview: cleanText(row.preview),
106
117
  generation: typeof row.generation === 'number' ? row.generation : 0,
107
118
  implicitBashSessionId: row.implicitBashSessionId || null,
119
+ detachedReason: row.detachedReason || null,
108
120
  };
109
121
  }
110
122
 
@@ -112,6 +124,10 @@ function rowFromSession(session, heartbeatAt = 0) {
112
124
  const messages = Array.isArray(session?.messages) ? session.messages : [];
113
125
  const preview = messages
114
126
  .filter((message) => message?.role === 'user')
127
+ // Cold-path mirror of isSessionPreviewNoise's synthetic skips: compact
128
+ // handoffs and runtime notices must not become session titles.
129
+ .filter((message) => !/^\s*(?:a previous model worked on this task|re-attached after compaction\b|reference files:\s|\[mixdog-runtime\]|the async (?:agent|shell) task\b)/i
130
+ .test(messageText(message.content)))
115
131
  .map((message) => cleanText(messageText(message.content)))
116
132
  .find(Boolean) || '';
117
133
  return normalizedRow({
@@ -153,7 +169,8 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes()) {
153
169
  // cold catalog; the authoritative runtime can reconcile it later.
154
170
  }
155
171
  }
156
- return rows.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
172
+ return rows.sort((left, right) =>
173
+ (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
157
174
  }
158
175
 
159
176
  export function listStoredSessionSummaries(options = {}) {
@@ -166,7 +183,8 @@ export function listStoredSessionSummaries(options = {}) {
166
183
  const rows = (Array.isArray(index.rows) ? index.rows : [])
167
184
  .map((row) => normalizedRow(row, heartbeatMtimes.get(row?.id) || 0))
168
185
  .filter((row) => row && isLeadVisibleRow(row))
169
- .sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
186
+ .sort((left, right) =>
187
+ (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
170
188
  if (rows.length > 0 || options.rebuildIfMissing === false) return rows;
171
189
  }
172
190
  } catch {
@@ -69,11 +69,12 @@ async function loadConfig({ freshSecrets = false } = {}) {
69
69
  // are no longer read. Done one-shots are dropped so they never re-arm.
70
70
  const scheduleEntries = (await listSchedules())
71
71
  .filter((s) => s.enabled !== false && s.status !== "done");
72
- // `target` drives routing: 'channel' non-interactive dispatch to the
73
- // schedule's channel_id; 'session' inject into the current (Lead)
74
- // session. Mirrors the webhook model.
75
- raw.nonInteractive = scheduleEntries.filter((s) => s.target === "channel");
76
- raw.interactive = scheduleEntries.filter((s) => s.target === "session");
72
+ // Every schedule fires as a visible session run (runScheduleSession)
73
+ // through the non-interactive dispatch path; `target` only decides
74
+ // whether the RESULT is relayed to the schedule's channel. The legacy
75
+ // interactive bucket (Lead-session inject) is retired.
76
+ raw.nonInteractive = scheduleEntries;
77
+ raw.interactive = [];
77
78
  const accessChannels = { ...raw.access?.channels ?? {} };
78
79
  // voice config lives at the top level of mixdog-config.json (peer of
79
80
  // channels), so readSection("channels") never sees it. Pull it explicitly.
@@ -156,6 +157,13 @@ const HEADLESS_BACKEND = {
156
157
  }
157
158
  };
158
159
  function createBackend(config) {
160
+ // Channels-module toggle is MESSAGING-ONLY: automation (scheduler/webhooks)
161
+ // keeps the worker alive, so a disabled module runs the headless backend
162
+ // instead of blocking the whole runtime.
163
+ if (config.enabled === false) {
164
+ process.stderr.write("mixdog: channels messaging disabled; channel runtime running in headless mode\n");
165
+ return HEADLESS_BACKEND;
166
+ }
159
167
  // Single-backend select: exactly one backend is constructed based on
160
168
  // config.backend (discord|telegram). The two are mutually exclusive.
161
169
  if (config.backend === "telegram") {
@@ -48,6 +48,11 @@ export function createOwnedRuntime({
48
48
  let _ownedRuntimeStopRequested = false;
49
49
  let bridgeOwnershipRefreshInFlight = null;
50
50
  let _memoryDrainTimer = null;
51
+ // Automation (scheduler + webhook server + relay tunnel) can outlive a
52
+ // failed/absent messaging backend: it is tracked separately so teardown and
53
+ // reload know it is running even while bridgeRuntimeConnected stays false.
54
+ let automationRunning = false;
55
+ let automationStartPromise = null;
51
56
  // Promise that resolves when the current startOwnedRuntime() run fully
52
57
  // settles (bridgeRuntimeStarting -> false). reloadRuntimeConfig awaits this
53
58
  // before issuing a restart so a backend swap that lands mid-start is not
@@ -133,6 +138,34 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
133
138
  setWebhookServer(null);
134
139
  }
135
140
  }
141
+ async function startAutomationRuntime() {
142
+ if (automationRunning) {
143
+ syncOwnedWebhookAndEventRuntime();
144
+ return;
145
+ }
146
+ if (automationStartPromise) return automationStartPromise;
147
+ if (!bridgeRuntimeStarting) _ownedRuntimeStopRequested = false;
148
+ const run = (async () => {
149
+ try {
150
+ const agentCfg = loadAgentConfig();
151
+ await initProviders(agentCfg.providers || {});
152
+ } catch (e) {
153
+ process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
154
+ }
155
+ if (_ownedRuntimeStopRequested) return;
156
+ scheduler.start();
157
+ startSnapshotWriter(scheduler);
158
+ syncOwnedWebhookAndEventRuntime();
159
+ automationRunning = true;
160
+ process.stderr.write('mixdog: automation runtime up without messaging backend\n');
161
+ })();
162
+ automationStartPromise = run;
163
+ try {
164
+ return await run;
165
+ } finally {
166
+ if (automationStartPromise === run) automationStartPromise = null;
167
+ }
168
+ }
136
169
  let _ownedRuntimeSelfHealing = false;
137
170
  // Explicit restore path for an already-connected owner. The 3s ownership tick
138
171
  // must not run this implicitly: re-binding status on every tick can move the
@@ -272,9 +305,14 @@ async function startOwnedRuntime(options = {}) {
272
305
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
273
306
  return;
274
307
  }
275
- scheduler.start();
276
- startSnapshotWriter(scheduler);
308
+ // Reconnect after a degraded (automation-only) start must not double-arm
309
+ // the scheduler/snapshot timers; the webhook/event sync is idempotent.
310
+ if (!automationRunning) {
311
+ scheduler.start();
312
+ startSnapshotWriter(scheduler);
313
+ }
277
314
  syncOwnedWebhookAndEventRuntime();
315
+ automationRunning = true;
278
316
  if (restoreBinding) {
279
317
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
280
318
  const pendingTranscriptPath = forwarder.transcriptPath;
@@ -303,6 +341,23 @@ async function startOwnedRuntime(options = {}) {
303
341
  try { releaseOwnedChannelLocks(instanceId); } catch {}
304
342
  try { clearActiveInstance(instanceId); } catch {}
305
343
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
344
+ // DEGRADED MODE (automation decoupling): a messaging connect failure —
345
+ // packaged runtime without discord.js, bad token, gateway outage — must
346
+ // not take schedules/webhooks down with it. Start the automation runtime
347
+ // anyway; sends stay dead (bridgeRuntimeConnected=false) but session
348
+ // runs, the webhook server, and the relay tunnel work.
349
+ if (!_ownedRuntimeStopRequested) {
350
+ if (automationRunning) {
351
+ // Already degraded-started by an earlier attempt; this run was only a
352
+ // messaging reconnect retry — the finally below settles the start.
353
+ return;
354
+ }
355
+ try {
356
+ await startAutomationRuntime();
357
+ } catch (e3) {
358
+ process.stderr.write(`mixdog: degraded automation start failed: ${e3 instanceof Error ? e3.message : String(e3)}\n`);
359
+ }
360
+ }
306
361
  } finally {
307
362
  settleInFlightStart();
308
363
  }
@@ -318,12 +373,15 @@ async function stopOwnedRuntime(reason) {
318
373
  // during that window (bridgeRuntimeStarting=true, getBridgeRuntimeConnected()
319
374
  // still false) we still need to tear that partial state down — otherwise
320
375
  // the port stays bound and active-instance.json stays stale.
321
- if (!getBridgeRuntimeConnected() && !bridgeRuntimeStarting) return;
376
+ if (!getBridgeRuntimeConnected() && !bridgeRuntimeStarting && !automationRunning && !automationStartPromise) return;
322
377
  // If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
323
378
  // startOwnedRuntime() to abort right after its getBackend().connect() resolves.
324
379
  // Without this the in-flight start re-marks connected and re-launches
325
380
  // scheduler/webhook/heartbeat after we tear them down here.
326
- if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
381
+ if (bridgeRuntimeStarting || automationStartPromise) _ownedRuntimeStopRequested = true;
382
+ if (automationStartPromise) {
383
+ try { await automationStartPromise; } catch {}
384
+ }
327
385
  const wasConnected = getBridgeRuntimeConnected();
328
386
  stopServerTyping();
329
387
  // Release the transcript fs.watch handle plus the forwarder's debounce/retry
@@ -335,6 +393,7 @@ async function stopOwnedRuntime(reason) {
335
393
  scheduler.stop();
336
394
  stopSnapshotWriter();
337
395
  await stopWebhookAndEventRuntime();
396
+ automationRunning = false;
338
397
  releaseOwnedChannelLocks(instanceId);
339
398
  clearActiveInstance(instanceId);
340
399
  try {
@@ -462,13 +521,14 @@ async function reloadRuntimeConfig() {
462
521
  } else if (nextBackend !== previousBackend) {
463
522
  try { await nextBackend.disconnect?.(); } catch {}
464
523
  }
465
- if (getBridgeRuntimeConnected()) {
524
+ if (getBridgeRuntimeConnected() || automationRunning) {
466
525
  syncOwnedWebhookAndEventRuntime({ reload: true });
467
526
  } else {
468
527
  await stopWebhookAndEventRuntime();
469
528
  }
470
529
  }
471
530
  return {
531
+ startAutomationRuntime,
472
532
  startOwnedRuntime,
473
533
  stopOwnedRuntime,
474
534
  refreshBridgeOwnership,
@@ -558,10 +558,10 @@ ${Scheduler.INSTANCE_UUID}`;
558
558
  `);
559
559
  return false;
560
560
  }
561
- // target 'channel' (non-interactive) dispatch to the schedule's
562
- // channel_id, falling back to the resolved main channel. target 'session'
563
- // (interactive) inject into the Lead session with no channel id.
564
- const channelId = type === "non-interactive"
561
+ // target 'channel' → relay the run result to the schedule's channel_id
562
+ // (falling back to the resolved main channel). target 'session' → no
563
+ // channel relay; the visible session run IS the surface.
564
+ const channelId = schedule.target === "channel"
565
565
  ? this.resolveChannel(schedule.channelId)
566
566
  : "";
567
567
  return await this.fireTimedPrompt(schedule, type, prompt, channelId, opts);
@@ -570,16 +570,8 @@ ${Scheduler.INSTANCE_UUID}`;
570
570
  async fireTimedPrompt(schedule, type, prompt, channelId, { awaitDispatch = false } = {}) {
571
571
  logSchedule(`firing ${schedule.name} (${type})
572
572
  `);
573
- if (type === "interactive") {
574
- if (this.injectFn) {
575
- this.injectFn(channelId, schedule.name, " ", {
576
- instruction: prompt,
577
- type: "schedule"
578
- });
579
- return true;
580
- }
581
- return false;
582
- }
573
+ // Legacy interactive Lead-session inject is retired: every schedule fire
574
+ // runs as its own visible session below (New-task parity, user decision).
583
575
  if (this.running.has(schedule.name)) return false;
584
576
  this.running.add(schedule.name);
585
577
  const presetId = schedule.model;
@@ -598,7 +590,7 @@ ${Scheduler.INSTANCE_UUID}`;
598
590
  const dispatch = runScheduleSession(schedule, { prompt })
599
591
  .then(({ result }) => {
600
592
  this.running.delete(schedule.name);
601
- if (result && this.sendFn) {
593
+ if (result && channelId && this.sendFn) {
602
594
  this.sendFn(channelId, result).catch(
603
595
  (err) => process.stderr.write(`mixdog scheduler: ${schedule.name} relay failed: ${err}\n`)
604
596
  );
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Writes <DATA_DIR>/channels/status-snapshot.json every 10 seconds so that
5
5
  * setup-server can read cross-process state (cron next-fire, deferred count,
6
- * Discord unread, ngrok tunnel URL) without IPC.
6
+ * Discord unread, relay hook URL) without IPC.
7
7
  *
8
8
  * Atomic write: tmp → rename so readers never see a partial file.
9
9
  *
@@ -13,10 +13,10 @@
13
13
  */
14
14
 
15
15
  import * as fs from 'fs';
16
- import * as http from 'http';
17
16
  import * as path from 'path';
18
17
  import { DATA_DIR } from './config.mjs';
19
18
  import { writeJsonAtomicSync } from '../../shared/atomic-file.mjs';
19
+ import { readHookPublicBase } from './webhook/relay-tunnel.mjs';
20
20
 
21
21
  const SNAPSHOT_DIR = path.join(DATA_DIR, 'channels');
22
22
  const SNAPSHOT_PATH = path.join(SNAPSHOT_DIR, 'status-snapshot.json');
@@ -79,30 +79,6 @@ export function recordFetchedMessages(channelId, channelLabel, messages, options
79
79
  });
80
80
  }
81
81
 
82
- // ── Ngrok tunnel URL probe ───────────────────────────────────────────────────
83
- async function probeNgrokUrl() {
84
- return new Promise((resolve) => {
85
- const timer = setTimeout(() => { try { req && req.destroy(); } catch {} resolve(null); }, 400);
86
- let req;
87
- try {
88
- req = http.get('http://127.0.0.1:4040/api/tunnels', (res) => {
89
- clearTimeout(timer);
90
- let body = '';
91
- res.on('data', d => { body += d; });
92
- res.on('end', () => {
93
- try {
94
- const parsed = JSON.parse(body);
95
- const tunnel = (parsed.tunnels || []).find(t => t.public_url);
96
- resolve(tunnel ? tunnel.public_url : null);
97
- } catch { resolve(null); }
98
- });
99
- });
100
- req.on('error', () => { clearTimeout(timer); resolve(null); });
101
- req.setTimeout(400, () => { clearTimeout(timer); try { req.destroy(); } catch {} resolve(null); });
102
- } catch { clearTimeout(timer); resolve(null); }
103
- });
104
- }
105
-
106
82
  // ── Snapshot computation ─────────────────────────────────────────────────────
107
83
  // The legacy HH:MM / everyNm / hourly next-fire fallback was removed: the
108
84
  // scheduler accepts cron expressions exclusively (scheduler.mjs:68), so the
@@ -172,8 +148,8 @@ async function computeSnapshot(scheduler) {
172
148
  totalUnread += entry.unseenCount;
173
149
  }
174
150
 
175
- // ── Ngrok tunnel URL ───────────────────────────────────────────────────────
176
- const tunnelUrl = await probeNgrokUrl();
151
+ // ── Relay hook URL (identity file read; assigned on first tunnel start) ────
152
+ const hookPublicUrl = readHookPublicBase();
177
153
 
178
154
  return {
179
155
  writtenAt: now,
@@ -188,8 +164,8 @@ async function computeSnapshot(scheduler) {
188
164
  unread: unreadList,
189
165
  totalUnread,
190
166
  },
191
- ngrok: {
192
- tunnelUrl,
167
+ hook: {
168
+ publicUrl: hookPublicUrl,
193
169
  },
194
170
  };
195
171
  }
@@ -27,6 +27,7 @@ function createToolDispatch({
27
27
  refreshBridgeOwnership,
28
28
  bindPersistedTranscriptIfAny,
29
29
  rebindCurrentTranscript,
30
+ startChannelBridge,
30
31
  stopOwnedRuntime,
31
32
  reloadRuntimeConfig,
32
33
  } = lifecycle;
@@ -60,7 +61,11 @@ function createToolDispatch({
60
61
  notifyRemoteAcquired?.();
61
62
  }
62
63
  try {
63
- await refreshBridgeOwnership({ restoreBinding: true });
64
+ if (typeof startChannelBridge === 'function') {
65
+ await startChannelBridge();
66
+ } else {
67
+ await refreshBridgeOwnership({ restoreBinding: true });
68
+ }
64
69
  // An already-connected owner returns early from
65
70
  // startOwnedRuntime(), so rebind explicitly to follow the
66
71
  // current (parent-chain) session transcript.
@@ -0,0 +1,203 @@
1
+ // Relay-backed public webhook tunnel — the ngrok child process replacement.
2
+ //
3
+ // The channel worker keeps ONE outbound WebSocket to the Mixdog relay
4
+ // (apps/relay/server.mjs `/hookleg`). Inbound requests on
5
+ // https://<relay>/hook/<deviceId>/webhook/<name>
6
+ // arrive over that leg as JSON frames and are replayed against the LOCAL
7
+ // webhook HTTP server; the response returns verbatim. Endpoint HMAC
8
+ // verification stays local — the relay never inspects payloads. Works out
9
+ // of the box: no binary, no authtoken, no reserved domain.
10
+ import * as http from "http";
11
+ import { randomBytes, randomUUID } from "crypto";
12
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
13
+ import { join } from "path";
14
+ import WebSocket from "ws";
15
+ import { DATA_DIR } from "../config.mjs";
16
+ import { logWebhook } from "./log.mjs";
17
+
18
+ /** Packaged default mirrors the desktop pairing relay. */
19
+ const DEFAULT_RELAY_URL = "wss://192-255-139-161.sslip.io";
20
+ const MAX_TUNNEL_BODY_BYTES = 1024 * 1024;
21
+ const HEARTBEAT_MS = 25_000;
22
+ const LOCAL_TIMEOUT_MS = 25_000;
23
+
24
+ export function resolveHookRelayUrl(env = process.env) {
25
+ const raw = String(env.MIXDOG_RELAY_URL || "").trim();
26
+ const flag = raw.toLowerCase();
27
+ if (flag === "0" || flag === "false" || flag === "off") return null;
28
+ return raw || DEFAULT_RELAY_URL;
29
+ }
30
+
31
+ export function hookIdentityPath() {
32
+ return join(DATA_DIR, "relay-hook-device.json");
33
+ }
34
+
35
+ // Stable per-install identity (trust-on-first-use at the relay, mirroring
36
+ // the desktop leg). The secret never leaves this machine except toward the
37
+ // relay; the deviceId doubles as the public URL path segment.
38
+ export function loadOrCreateHookIdentity() {
39
+ const path = hookIdentityPath();
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
42
+ if (typeof parsed.deviceId === "string" && /^[0-9a-f-]{8,64}$/.test(parsed.deviceId)
43
+ && typeof parsed.deviceSecret === "string" && parsed.deviceSecret.length >= 16) {
44
+ return { deviceId: parsed.deviceId, deviceSecret: parsed.deviceSecret };
45
+ }
46
+ } catch { /* first run */ }
47
+ const identity = { deviceId: randomUUID(), deviceSecret: randomBytes(24).toString("hex") };
48
+ try {
49
+ mkdirSync(DATA_DIR, { recursive: true });
50
+ writeFileSync(path, JSON.stringify(identity, null, 2), "utf8");
51
+ } catch (err) {
52
+ logWebhook(`hook tunnel: identity persist failed — ${err?.message || err}`);
53
+ }
54
+ return identity;
55
+ }
56
+
57
+ export function hookPublicBase(relayUrl, deviceId) {
58
+ const url = new URL(relayUrl);
59
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
60
+ url.pathname = `/hook/${deviceId}`;
61
+ url.search = "";
62
+ url.hash = "";
63
+ return url.toString();
64
+ }
65
+
66
+ /** Public base URL for status surfaces; null until the first tunnel start
67
+ * persisted an identity (no identity is ever created here). */
68
+ export function readHookPublicBase(env = process.env) {
69
+ const relayUrl = resolveHookRelayUrl(env);
70
+ if (!relayUrl) return null;
71
+ try {
72
+ const parsed = JSON.parse(readFileSync(hookIdentityPath(), "utf8"));
73
+ if (typeof parsed.deviceId === "string" && parsed.deviceId) {
74
+ return hookPublicBase(relayUrl, parsed.deviceId);
75
+ }
76
+ } catch { /* tunnel has not started yet */ }
77
+ return null;
78
+ }
79
+
80
+ export function startHookTunnel({ relayUrl, getLocalPort }) {
81
+ const { deviceId, deviceSecret } = loadOrCreateHookIdentity();
82
+ let socket = null;
83
+ let closed = false;
84
+ let retryMs = 1_000;
85
+ let reconnectTimer = null;
86
+ let announced = false;
87
+
88
+ const scheduleReconnect = () => {
89
+ if (closed) return;
90
+ reconnectTimer = setTimeout(connect, retryMs);
91
+ reconnectTimer.unref?.();
92
+ retryMs = Math.min(30_000, retryMs * 2);
93
+ };
94
+
95
+ const respond = (ws, id, status, headers, bodyBuffer) => {
96
+ if (ws.readyState !== WebSocket.OPEN) return;
97
+ try {
98
+ ws.send(JSON.stringify({
99
+ type: "http-response",
100
+ id,
101
+ status,
102
+ headers: headers || {},
103
+ body: bodyBuffer && bodyBuffer.length ? bodyBuffer.toString("base64") : "",
104
+ }));
105
+ } catch { /* relay vanished; it times the request out */ }
106
+ };
107
+
108
+ const forwardToLocal = (frame, ws) => {
109
+ const port = getLocalPort();
110
+ if (!port) {
111
+ respond(ws, frame.id, 503, { "content-type": "application/json" },
112
+ Buffer.from('{"error":"webhook server not listening"}'));
113
+ return;
114
+ }
115
+ const body = frame.body ? Buffer.from(String(frame.body), "base64") : null;
116
+ const request = http.request({
117
+ host: "127.0.0.1",
118
+ port,
119
+ method: typeof frame.method === "string" ? frame.method : "GET",
120
+ path: typeof frame.path === "string" && frame.path.startsWith("/") ? frame.path : "/",
121
+ headers: frame.headers && typeof frame.headers === "object" ? frame.headers : {},
122
+ timeout: LOCAL_TIMEOUT_MS,
123
+ }, (response) => {
124
+ const chunks = [];
125
+ let total = 0;
126
+ response.on("data", (chunk) => {
127
+ total += chunk.length;
128
+ if (total <= MAX_TUNNEL_BODY_BYTES) chunks.push(chunk);
129
+ });
130
+ response.on("end", () => respond(ws, frame.id, response.statusCode || 502,
131
+ { "content-type": response.headers["content-type"] || "application/json" },
132
+ Buffer.concat(chunks)));
133
+ response.on("error", () => respond(ws, frame.id, 502, {}, null));
134
+ });
135
+ request.on("timeout", () => request.destroy(new Error("local webhook timeout")));
136
+ request.on("error", (err) => respond(ws, frame.id, 502, { "content-type": "application/json" },
137
+ Buffer.from(JSON.stringify({ error: String(err?.message || err) }))));
138
+ if (body && body.length) request.write(body);
139
+ request.end();
140
+ };
141
+
142
+ const connect = () => {
143
+ if (closed) return;
144
+ const target = new URL(relayUrl);
145
+ target.pathname = "/hookleg";
146
+ target.search = `device=${encodeURIComponent(deviceId)}&secret=${encodeURIComponent(deviceSecret)}`;
147
+ let ws;
148
+ try {
149
+ ws = new WebSocket(target.toString(), { maxPayload: 8 * 1024 * 1024 });
150
+ } catch (err) {
151
+ logWebhook(`hook tunnel: dial failed — ${err?.message || err}`);
152
+ scheduleReconnect();
153
+ return;
154
+ }
155
+ socket = ws;
156
+ // NAT paths silently drop idle sockets; protocol pings keep the leg warm
157
+ // and detect a half-dead link so the reconnect loop restores it.
158
+ let alive = true;
159
+ ws.on("pong", () => { alive = true; });
160
+ const heartbeat = setInterval(() => {
161
+ if (ws.readyState !== WebSocket.OPEN) return;
162
+ if (!alive) { try { ws.terminate(); } catch { /* close reconnects */ } return; }
163
+ alive = false;
164
+ try { ws.ping(); } catch { /* close reconnects */ }
165
+ }, HEARTBEAT_MS);
166
+ heartbeat.unref?.();
167
+ ws.on("open", () => {
168
+ retryMs = 1_000;
169
+ if (!announced) {
170
+ announced = true;
171
+ logWebhook(`hook tunnel up: ${hookPublicBase(relayUrl, deviceId)}`);
172
+ }
173
+ });
174
+ ws.on("message", (raw) => {
175
+ alive = true;
176
+ let frame;
177
+ try { frame = JSON.parse(String(raw)); } catch { return; }
178
+ if (frame?.type !== "http" || typeof frame.id !== "string") return;
179
+ forwardToLocal(frame, ws);
180
+ });
181
+ ws.on("error", () => { /* surfaced as close */ });
182
+ ws.on("close", () => {
183
+ clearInterval(heartbeat);
184
+ if (socket === ws) socket = null;
185
+ scheduleReconnect();
186
+ });
187
+ };
188
+
189
+ connect();
190
+ return {
191
+ deviceId,
192
+ publicBase: hookPublicBase(relayUrl, deviceId),
193
+ close() {
194
+ if (closed) return;
195
+ closed = true;
196
+ if (reconnectTimer) clearTimeout(reconnectTimer);
197
+ if (socket) {
198
+ try { socket.terminate(); } catch { /* already gone */ }
199
+ socket = null;
200
+ }
201
+ },
202
+ };
203
+ }