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
@@ -1,10 +1,7 @@
1
1
  import * as http from "http";
2
- import { spawn, spawnSync } from "child_process";
3
2
  import { randomUUID } from "crypto";
4
- import { getWebhookAuthtoken } from "../../shared/config.mjs";
5
3
  import { logWebhook } from "./webhook/log.mjs";
6
4
  import { SIGNATURE_HEADERS, extractSignature, STRIPE_TOLERANCE_MS, verifySignature } from "./webhook/signature.mjs";
7
- import { detachedSpawnOpts } from "../../shared/spawn-flags.mjs";
8
5
  import {
9
6
  loadEndpointConfig,
10
7
  readEndpointSecret,
@@ -15,20 +12,7 @@ import {
15
12
  extractDeliveryId,
16
13
  buildHeadersSummary,
17
14
  } from "./webhook/deliveries.mjs";
18
- import {
19
- NGROK_MAX_AGE_MS,
20
- resolveNgrokBin,
21
- normalizeDomain,
22
- readNgrokMeta,
23
- writeNgrokMeta,
24
- clearNgrokMeta,
25
- isLikelyNgrok,
26
- isProcessAlive,
27
- parseStrictPidLine,
28
- resolvePortOwnerPid,
29
- handleWebhookPortInUse,
30
- checkNgrokHealth,
31
- } from "./webhook/ngrok.mjs";
15
+ import { resolveHookRelayUrl, startHookTunnel } from "./webhook/relay-tunnel.mjs";
32
16
 
33
17
  class WebhookServer {
34
18
  config;
@@ -38,7 +22,7 @@ class WebhookServer {
38
22
  boundPort = 0;
39
23
  listenInFlight = false;
40
24
  noSecretWarned = false;
41
- ngrokProcess = null;
25
+ hookTunnel = null;
42
26
  constructor(config) {
43
27
  this.config = config;
44
28
  }
@@ -311,42 +295,19 @@ class WebhookServer {
311
295
  const basePort = this.config.port || 3333;
312
296
  const maxPort = basePort + 7;
313
297
  let currentPort = basePort;
314
- let baseReclaimAttempted = false;
315
298
  const tryListen = () => {
316
299
  this.server.listen(currentPort, () => {
317
300
  this.listenInFlight = false;
318
301
  this.boundPort = currentPort;
319
302
  logWebhook(`listening on port ${currentPort}`);
320
- this.startNgrok();
303
+ this._startHookTunnel();
321
304
  });
322
305
  };
323
306
  this.server.on("error", (err) => {
324
- if (err.code === "EADDRINUSE" && currentPort === basePort && !baseReclaimAttempted) {
325
- baseReclaimAttempted = true;
326
- void handleWebhookPortInUse(basePort, this.config.ngrokDomain || this.config.domain).then((result) => {
327
- if (result.ok) {
328
- currentPort = basePort;
329
- logWebhook(`reclaimed base port ${basePort}, retrying bind`);
330
- tryListen();
331
- return;
332
- }
333
- if (result.bump && currentPort < maxPort) {
334
- logWebhook(
335
- `port ${basePort} not reclaimable (live non-ngrok PID ${result.ownerPid ?? "unknown"}), trying ${currentPort + 1}`,
336
- );
337
- currentPort++;
338
- tryListen();
339
- return;
340
- }
341
- if (err.code === "EADDRINUSE") {
342
- logWebhook(`all ports ${basePort}-${maxPort} in use \u2014 webhook server disabled`);
343
- this.listenInFlight = false;
344
- this.server = null;
345
- }
346
- });
347
- return;
348
- }
349
307
  if (err.code === "EADDRINUSE" && currentPort < maxPort) {
308
+ // The relay tunnel forwards to whatever port this process binds, so
309
+ // port identity no longer matters (the ngrok-era domain↔port coupling
310
+ // is gone) — walk up the range.
350
311
  logWebhook(`port ${currentPort} already in use, trying ${currentPort + 1}`);
351
312
  currentPort++;
352
313
  tryListen();
@@ -364,166 +325,27 @@ class WebhookServer {
364
325
  });
365
326
  tryListen();
366
327
  }
367
- /**
368
- * Check if a previous ngrok process can be reused.
369
- * Returns true if the existing ngrok is alive, healthy, and serving the right domain.
370
- * Returns false otherwise. External processes are never killed; stale or
371
- * incompatible metadata is ignored so another terminal's tunnel stays alive.
372
- */
373
- async reuseNgrokIfHealthy(domain, expectedPort = null) {
374
- const meta = readNgrokMeta();
375
- if (!meta || !(meta.pid > 0)) {
376
- clearNgrokMeta();
377
- return false;
378
- }
379
-
380
- const { pid } = meta;
381
-
382
- // Metadata domain mismatch — different config. Do not kill another terminal's tunnel.
383
- if (meta.domain && normalizeDomain(meta.domain) !== normalizeDomain(domain)) {
384
- logWebhook(`ngrok meta domain mismatch (${meta.domain} vs ${domain}), ignoring PID ${pid}`);
385
- clearNgrokMeta();
386
- return false;
387
- }
388
- if (expectedPort && meta.port && Number(meta.port) !== Number(expectedPort)) {
389
- // A tunnel forwarding to the OLD local port cannot serve this server.
390
- // Ignore the metadata and let this process try its own tunnel without
391
- // touching the existing process.
392
- logWebhook(`ngrok meta port mismatch (${meta.port} vs ${expectedPort}) — ignoring PID ${pid}`);
393
- clearNgrokMeta();
394
- return false;
395
- }
396
-
397
- // Stale check — older than 24 hours (ngrok session realistic lifetime;
398
- // ngrok free-tier tunnels expire after ~2h but paid/reserved-domain
399
- // tunnels survive much longer; 24h is a safe conservative ceiling).
400
- if (meta.startedAt && (Date.now() - new Date(meta.startedAt).getTime()) > NGROK_MAX_AGE_MS) {
401
- logWebhook(`ngrok meta stale (started ${meta.startedAt}), ignoring PID ${pid}`);
402
- clearNgrokMeta();
403
- return false;
404
- }
405
-
406
- // Check if process is alive
407
- let alive = false;
408
- try { process.kill(pid, 0); alive = true } catch {}
409
-
410
- if (!alive) {
411
- logWebhook(`ngrok PID ${pid} is dead, cleaning up`);
412
- clearNgrokMeta();
413
- return false;
414
- }
415
-
416
- // Process alive + domain matches — verify tunnel via 4040 API
417
- const healthy = await checkNgrokHealth(domain, expectedPort);
418
- if (healthy) {
419
- logWebhook(`reusing ngrok (PID ${pid}, domain ${domain}, port ${meta.port})`);
420
- return true;
421
- }
422
-
423
- // Alive but tunnel unhealthy. Leave it alone; it may belong to another terminal.
424
- logWebhook(`ngrok PID ${pid} alive but tunnel unhealthy, ignoring`);
425
- clearNgrokMeta();
426
- return false;
427
- }
428
- async startNgrok() {
429
- // Mutex: skip only when THIS process still owns a live ngrok child. Fresh
430
- // daemon restarts always have ngrokProcess=null and must proceed; stale
431
- // in-memory refs after exit must not block respawn.
432
- if (this.ngrokProcess && this.ngrokProcess.exitCode == null && !this.ngrokProcess.killed) return;
433
- if (this._ngrokStartPromise) return this._ngrokStartPromise;
434
- this._ngrokStartPromise = this._doStartNgrok();
435
- try { await this._ngrokStartPromise; } finally { this._ngrokStartPromise = null; }
436
- }
437
- async _doStartNgrok() {
438
- const authtoken = getWebhookAuthtoken();
439
- const domain = this.config.ngrokDomain || this.config.domain;
440
- if (!authtoken || !domain) return;
441
- let attempts = 0;
442
- while (!this.boundPort) {
443
- if (++attempts > 30) {
444
- logWebhook("ngrok: gave up waiting for port");
445
- return;
446
- }
447
- await new Promise((r) => setTimeout(r, 500));
448
- }
449
-
450
- // Try to reuse an existing ngrok process
451
- const reused = await this.reuseNgrokIfHealthy(domain, this.boundPort);
452
- if (reused) {
328
+ // Relay-backed public tunnel (replaces the ngrok child): one outbound
329
+ // WebSocket to the Mixdog relay serves every inbound webhook — no binary,
330
+ // no authtoken, no reserved domain.
331
+ _startHookTunnel() {
332
+ if (this.hookTunnel) return;
333
+ const relayUrl = resolveHookRelayUrl();
334
+ if (!relayUrl) {
335
+ logWebhook("hook tunnel disabled (MIXDOG_RELAY_URL=off)");
453
336
  return;
454
337
  }
455
-
456
- let ngrokBin;
457
338
  try {
458
- ngrokBin = resolveNgrokBin();
339
+ this.hookTunnel = startHookTunnel({ relayUrl, getLocalPort: () => this.boundPort });
340
+ logWebhook(`public hook base: ${this.hookTunnel.publicBase}`);
459
341
  } catch (err) {
460
- if (!this._ngrokDisabledLogged) {
461
- logWebhook(`ngrok disabled — ${err.message}`);
462
- this._ngrokDisabledLogged = true;
463
- }
464
- return;
465
- }
466
- spawnSync(ngrokBin, ["config", "add-authtoken", authtoken], { stdio: "ignore", timeout: 1e4, windowsHide: true });
467
- attempts = 0;
468
- const waitAndStart = () => {
469
- if (!this.boundPort) {
470
- if (++attempts > 30) {
471
- logWebhook("ngrok: gave up waiting for port");
472
- return;
473
- }
474
- setTimeout(waitAndStart, 500);
475
- return;
476
- }
477
- try {
478
- // stdio fully ignored so Node does not pass inheritable stdio handles
479
- // (bInheritHandles stays false on Windows). There is no portable Node API
480
- // to mark the http.Server listen socket non-inheritable; detached ngrok
481
- // can still inherit stale handles in edge cases — layer-1 port reclaim
482
- // on EADDRINUSE is the guaranteed safety net.
483
- this.ngrokProcess = spawn(ngrokBin, ["http", String(this.boundPort), "--url=" + domain], {
484
- stdio: ["ignore", "ignore", "ignore"],
485
- ...detachedSpawnOpts,
486
- });
487
- this.ngrokProcess.unref();
488
- if (this.ngrokProcess.pid) {
489
- writeNgrokMeta({
490
- pid: this.ngrokProcess.pid,
491
- domain,
492
- port: this.boundPort,
493
- startedAt: new Date().toISOString(),
494
- binaryPath: ngrokBin,
495
- });
496
- }
497
- this.ngrokProcess.on("exit", () => {
498
- this.ngrokProcess = null;
499
- clearNgrokMeta();
500
- });
501
- this.ngrokProcess.on("error", () => {
502
- this.ngrokProcess = null;
503
- clearNgrokMeta();
504
- });
505
- logWebhook(`ngrok tunnel started: ${domain} \u2192 localhost:${this.boundPort} (PID ${this.ngrokProcess.pid})`);
506
- } catch (e) {
507
- logWebhook(`ngrok start failed: ${e}`);
508
- }
509
- };
510
- setTimeout(waitAndStart, 1e3);
511
- // Hold the outer startNgrok() mutex (`_ngrokStartPromise`) until
512
- // waitAndStart actually spawns ngrok OR exhausts its 30-attempt
513
- // budget. Pre-fix the mutex released as soon as the setTimeout was
514
- // scheduled, letting a duplicate startNgrok() call within the wait
515
- // window arm a second timer and spawn a second ngrok process.
516
- // Deadline: 1s initial + 30 × 500ms attempts = 16s, +1.5s slack.
517
- const _deadline = Date.now() + 17500;
518
- while (!this.ngrokProcess && Date.now() < _deadline) {
519
- await new Promise((r) => setTimeout(r, 100));
342
+ logWebhook(`hook tunnel start failed: ${err?.message || err}`);
520
343
  }
521
344
  }
522
345
  stop() {
523
- // Intentionally do NOT kill ngrok — let it survive across MCP restarts.
524
- // The next start() can reuse it if reuseNgrokIfHealthy() validates it.
525
- if (this.ngrokProcess) {
526
- this.ngrokProcess = null;
346
+ if (this.hookTunnel) {
347
+ try { this.hookTunnel.close(); } catch { /* already closed */ }
348
+ this.hookTunnel = null;
527
349
  }
528
350
  let closed = Promise.resolve();
529
351
  if (this.server) {
@@ -538,7 +360,7 @@ class WebhookServer {
538
360
  }
539
361
  });
540
362
  }
541
- logWebhook("stopped (ngrok left running for reuse)");
363
+ logWebhook("stopped");
542
364
  return closed;
543
365
  }
544
366
  // reloadConfig(webhookCfg, options?)
@@ -575,24 +397,27 @@ ${headersSummary}
575
397
  ${payload}
576
398
  <<<${_UNTRUSTED}_END>>>`;
577
399
  }
578
- async _dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channel) {
400
+ async _dispatchSessionRun(name, model, fullPrompt, headers, deliveryId, res, extra = {}) {
579
401
  await updateDeliveryStatus(name, deliveryId, "processing");
580
- // Bridge dispatch must not be allowed to hang forever — without a
402
+ // Session dispatch must not be allowed to hang forever — without a
581
403
  // ceiling a stuck LLM call leaves the delivery in `processing`
582
404
  // for the lifetime of the process and dedup keeps re-running
583
- // forever. 10 minutes covers the slowest delegate task we ship.
405
+ // forever. 10 minutes covers the slowest handler run we ship.
584
406
  const DISPATCH_TIMEOUT_MS = 10 * 60 * 1000;
585
407
  let timeoutHandle = null;
586
408
  const dispatchP = Promise.resolve(this.bridgeDispatch({
587
- role,
588
- preset: model,
409
+ model: model || null,
589
410
  prompt: fullPrompt,
590
- cwd: this.config?.cwd,
411
+ // Endpoint-scoped project/workflow (New-task parity): the webhook row's
412
+ // cwd/workflow define the created session, not the worker's global cwd.
413
+ cwd: extra.cwd || this.config?.cwd || null,
414
+ workflow: extra.workflow || null,
415
+ attachments: extra.attachments || null,
416
+ delivery: extra.delivery || null,
591
417
  context: {
592
418
  source: "webhook",
593
419
  endpoint: name,
594
420
  deliveryId,
595
- channel: channel || null,
596
421
  event: headers["x-github-event"] || null,
597
422
  },
598
423
  }));
@@ -605,54 +430,29 @@ ${payload}
605
430
  Promise.race([dispatchP, timeoutP]).then(() => {
606
431
  if (timeoutHandle) clearTimeout(timeoutHandle);
607
432
  void updateDeliveryStatus(name, deliveryId, "done").catch((e) => logWebhook(`${name}: delivery status update failed: ${e?.message || e}`));
608
- logWebhook(`${name}: delegate dispatched to bridge (role=${role}, id=${deliveryId})`);
433
+ logWebhook(`${name}: webhook session run dispatched (id=${deliveryId})`);
609
434
  }).catch((err) => {
610
435
  if (timeoutHandle) clearTimeout(timeoutHandle);
611
436
  void updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) }).catch(() => {});
612
- logWebhook(`${name}: delegate dispatch failed: ${err?.message || err}`);
437
+ logWebhook(`${name}: webhook session run failed: ${err?.message || err}`);
613
438
  });
614
439
  res.writeHead(202, { "Content-Type": "application/json" });
615
- res.end(JSON.stringify({ status: "accepted", handler: "delegate", id: deliveryId }));
440
+ res.end(JSON.stringify({ status: "accepted", handler: "session", id: deliveryId }));
616
441
  }
617
442
  async handleWebhook(name, body, headers, res, deliveryId, endpoint) {
618
- // A PG endpoint row is the folder-handler equivalent: routing is by
619
- // channel_id presence WITH a channel delegate dispatch to the row's
620
- // role/model and report to that channel; WITHOUT interactive enqueue
621
- // into the current (Lead) session. Parser-only endpoints (no table row)
622
- // fall through to the event pipeline below.
443
+ // A PG endpoint row runs as a VISIBLE webhook session (user decision):
444
+ // no Lead injection, no channel delivery the run lands in Recent like
445
+ // a schedule run, and its session content IS the result surface.
446
+ // Parser-only endpoints (no table row) fall through to the event
447
+ // pipeline below.
623
448
  if (endpoint) {
624
449
  try {
625
- const { channelId, role, model, instructions } = endpoint;
450
+ const { model, instructions, cwd, workflow, attachments, delivery } = endpoint;
626
451
  const payloadContent = this._buildFencedPayload(body, headers);
627
- if (channelId) {
628
- if (!role) {
629
- await updateDeliveryStatus(name, deliveryId, "failed", { error: "delegate mode requires role" });
630
- logWebhook(`${name}: delegate mode requires role - rejected`);
631
- res.writeHead(400, { "Content-Type": "application/json" });
632
- res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires role" }));
633
- return;
634
- } else if (!model) {
635
- await updateDeliveryStatus(name, deliveryId, "failed", { error: "delegate mode requires model" });
636
- logWebhook(`${name}: delegate mode requires model - rejected`);
637
- res.writeHead(400, { "Content-Type": "application/json" });
638
- res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires model" }));
639
- return;
640
- } else if (!this.bridgeDispatch) {
641
- throw new Error(`[webhook] delegate mode requires bridgeDispatch`);
642
- } else {
643
- const fullPrompt = `${instructions}\n\n${payloadContent}`;
644
- await this._dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channelId);
645
- return;
646
- }
647
- }
648
- if (this.eventPipeline) {
649
- await updateDeliveryStatus(name, deliveryId, "processing");
650
- this.eventPipeline.enqueueDirect(name, payloadContent, channelId, "interactive", instructions);
651
- await updateDeliveryStatus(name, deliveryId, "done");
652
- logWebhook(`${name}: interactive enqueued (id=${deliveryId})`);
653
- }
654
- res.writeHead(202, { "Content-Type": "application/json" });
655
- res.end(JSON.stringify({ status: "accepted", handler: "interactive", id: deliveryId }));
452
+ if (!this.bridgeDispatch) throw new Error(`[webhook] session dispatch requires bridgeDispatch`);
453
+ const fullPrompt = `${instructions}\n\n${payloadContent}`;
454
+ await this._dispatchSessionRun(name, model || null, fullPrompt, headers, deliveryId, res,
455
+ { cwd: cwd || null, workflow: workflow || null, attachments: attachments || null, delivery: delivery || null });
656
456
  return;
657
457
  } catch (err) {
658
458
  await updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) });
@@ -673,8 +473,8 @@ ${payload}
673
473
  }
674
474
  /** Get the webhook URL for an endpoint name */
675
475
  getUrl(name) {
676
- if (this.config.ngrokDomain) {
677
- return `https://${this.config.ngrokDomain}/webhook/${name}`;
476
+ if (this.hookTunnel) {
477
+ return `${this.hookTunnel.publicBase}/webhook/${name}`;
678
478
  }
679
479
  return `http://localhost:${this.boundPort || this.config.port}/webhook/${name}`;
680
480
  }
@@ -331,6 +331,7 @@ const {
331
331
  // flags + ownership timer + memory-drain timer; shares config/backend/
332
332
  // bridgeRuntimeConnected/webhookServer/eventPipeline with the worker via get/set.
333
333
  const {
334
+ startAutomationRuntime,
334
335
  startOwnedRuntime,
335
336
  stopOwnedRuntime,
336
337
  refreshBridgeOwnership,
@@ -456,88 +457,31 @@ scheduler.setSendHandler(async (channelId, text) => {
456
457
  function wireWebhookHandlers() {
457
458
  if (!webhookServer) return;
458
459
  webhookServer.setEventPipeline(eventPipeline);
459
- webhookServer.setBridgeDispatch(async ({ role, preset, prompt, cwd, context }) => {
460
- // Delegate-mode webhookbridge orchestrator. Each bridge progress /
461
- // final event is forwarded to the Lead via the same channel-notify
462
- // path used by schedule & event-queue (injectAndRecord). Silent
463
- // lifecycle pings keep routing only to Discord.
464
- const [{ createStandaloneAgent }, cfgMod, reg, mgr] = await Promise.all([
465
- import("../../../standalone/agent-tool.mjs"),
466
- import("../../agent/orchestrator/config.mjs"),
467
- import("../../agent/orchestrator/providers/registry.mjs"),
468
- import("../../agent/orchestrator/session/manager.mjs"),
469
- ]);
470
- const agentTool = createStandaloneAgent({
471
- cfgMod,
472
- reg,
473
- mgr,
474
- dataDir: cfgMod.getPluginData(),
475
- cwd,
460
+ // Webhook fires run as sessions (schedules parity). Delivery mode:
461
+ // 'app' (default)the Automations session row IS the surface;
462
+ // 'channel'/'both' the run result also relays to the main channel.
463
+ webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context }) => {
464
+ const { runWebhookSession } = await import("../../shared/webhook-session-run.mjs");
465
+ const run = await runWebhookSession({
466
+ name: context?.endpoint || "webhook",
467
+ model: model || null,
468
+ cwd: cwd || null,
469
+ workflow: workflow || null,
470
+ attachments: attachments || null,
471
+ delivery: delivery || null,
472
+ prompt,
476
473
  });
477
- const channelId = resolveWebhookChannelId(context?.channel);
478
- const endpoint = context?.endpoint || "unknown";
479
- const event = context?.event || null;
480
- const deliveryId = context?.deliveryId || null;
481
- const label = `webhook:${endpoint}`;
482
- const instruction = `Webhook review from role=${role} on endpoint "${endpoint}"`
483
- + (event ? ` (event=${event})` : "")
484
- + (deliveryId ? ` (delivery=${deliveryId})` : "")
485
- + ". Relay the finding to the user naturally — summarize clearly, call out any issues, and note what needs a decision.";
486
- const notifyFn = (text, meta = {}) => {
487
- if (!text) return;
488
- // Webhook skip protocol: when the agent worker emits a `[meta:silent]`
489
- // marker (optionally behind model/role tag prefixes), the event is a
490
- // no-op (label-only, dedup, "nothing to report"). Drop the message
491
- // entirely — neither Lead inject nor Discord forward — instead of the
492
- // partial `silent_to_agent` semantics that still audit to Discord.
493
- const raw = String(text);
494
- if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
495
- // Deterministic findings-count drop. Code-review handlers emit a
496
- // structured `[[findings:N]]` token (N = number of issues). The RELAY —
497
- // not the worker's prose — decides: N==0 => clean review, drop entirely
498
- // (no Lead inject, no Discord forward). Token absent => fail-safe forward
499
- // so a real finding is never silently dropped if the worker omits it.
500
- const fc = raw.match(/\[\[findings:(\d+)\]\]/i);
501
- if (fc && Number(fc[1]) === 0) return;
502
- // Lifecycle pings (started / iter echoes, marked silent_to_agent) are
503
- // channel noise for an automated webhook review — drop them entirely so
504
- // a skip stays fully silent and only the final answer reaches the
505
- // channel. The final [meta:silent] skip result is already dropped above.
506
- if (meta?.silent_to_agent === true) return;
507
- // Strip the verdict token before surfacing (findings present, N>0).
508
- const surfaced = raw.replace(/\[\[findings:\d+\]\]/gi, "").replace(/[^\S\n]{2,}/g, " ").trim();
509
- injectAndRecord(channelId, label, surfaced || raw, {
510
- type: "webhook",
511
- instruction,
512
- });
513
- };
514
- // Per-terminal cwd under the daemon's single channels worker. A webhook
515
- // result is injected to ownerConn() — the connection whose session.leadPid
516
- // equals active-instance ownerLeadPid — so the worker must run in THAT
517
- // owner terminal's cwd. Read the sentinel keyed by ownerLeadPid; cwd-tool
518
- // writes session-cwd-<leadPid>.txt per connection, so write and read meet
519
- // on the same leadPid key no matter which terminal holds the owner seat.
520
- // Falls back to the session entry position; never the plugin CACHE root.
521
- const ownerPid = getActiveOwnerPid(readActiveInstance());
522
- const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
523
- return agentTool.execute(
524
- { type: "spawn", agent: role, preset, prompt, cwd: cwd || ownerCwd },
525
- { callerCwd: cwd || ownerCwd, invocationSource: "webhook", notifyFn },
526
- );
474
+ const mode = String(delivery || "app");
475
+ if ((mode === "channel" || mode === "both") && run?.result && backend?.sendMessage) {
476
+ const target = scheduler.resolveChannel("");
477
+ if (target) {
478
+ void bindingReady.then(() => backend.sendMessage(target, run.result))
479
+ .catch((err) => dropTrace("send.webhook.err", { channelId: target, err: String(err) }));
480
+ }
481
+ }
482
+ return run;
527
483
  });
528
484
  }
529
- function resolveWebhookChannelId(channelFlag) {
530
- // Single main channel: the endpoint's `channel` frontmatter is a boolean-ish
531
- // flag (any non-empty value except "false" → post to the main channel). A raw
532
- // channel id is still honored verbatim for owner-authored overrides.
533
- const main = config?.channelId || "";
534
- if (channelFlag == null) return main;
535
- const v = String(channelFlag).trim();
536
- if (v === "" || v.toLowerCase() === "false") return "";
537
- // A pure-digit / snowflake-looking value is treated as an explicit id;
538
- // anything else (legacy labels like "main") maps to the main channel.
539
- return /^-?\d+$/.test(v) ? v : main;
540
- }
541
485
  function wireEventQueueHandlers(eventQueue) {
542
486
  if (!eventQueue) return;
543
487
  eventQueue.setInjectHandler((channelId, name, content, options) => {
@@ -669,6 +613,7 @@ const {
669
613
  recoverUnsyncedTail: true,
670
614
  });
671
615
  },
616
+ startChannelBridge: () => start({ messaging: true }),
672
617
  stopOwnedRuntime,
673
618
  reloadRuntimeConfig,
674
619
  },
@@ -707,7 +652,24 @@ async function init(_sharedMcp) {
707
652
  injectAndRecord(channelId, name, content, options);
708
653
  });
709
654
  }
710
- async function start() {
655
+ function ensureConfigWatcher() {
656
+ if (_configWatcher) return;
657
+ try {
658
+ _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
659
+ invalidateConfigReadCache();
660
+ if (_reloadDebounce) clearTimeout(_reloadDebounce);
661
+ _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
662
+ });
663
+ } catch {}
664
+ }
665
+ async function start(options = {}) {
666
+ if (options?.messaging === false) {
667
+ channelBridgeActive = false;
668
+ writeBridgeState(false);
669
+ await startAutomationRuntime();
670
+ ensureConfigWatcher();
671
+ return;
672
+ }
711
673
  channelBridgeActive = true;
712
674
  writeBridgeState(true);
713
675
  // Daemon model: this runtime is the machine-global singleton bridge owner
@@ -729,18 +691,9 @@ async function start() {
729
691
  // ownership timer — the singleton daemon guarantees exactly one owner.
730
692
  armBridgeOwnershipTimer();
731
693
  // Hot-reload config on file change (schedules/webhooks/events).
732
- if (!_configWatcher) {
733
- try {
734
- _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
735
- // Cross-process edit landed on disk; drop this process's short-TTL raw
736
- // config cache synchronously so the debounced reload (and any readAll
737
- // in between) sees the fresh file immediately, not up to TTL stale.
738
- invalidateConfigReadCache();
739
- if (_reloadDebounce) clearTimeout(_reloadDebounce);
740
- _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
741
- });
742
- } catch {}
743
- }
694
+ // Cross-process edits invalidate the raw config cache before the debounced
695
+ // reload so both messaging and automation-only daemon modes stay current.
696
+ ensureConfigWatcher();
744
697
  // Pre-warm the whisper-server manager once at owner startup so the first
745
698
  // voice transcription does not pay cold-start cost. Non-blocking: failures
746
699
  // (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stored automation attachments (schedule/webhook rows) → askSession content.
3
+ * Attachment shape (persisted as jsonb): { kind: 'image'|'text'|'pdf', name,
4
+ * mimeType, data } — image/pdf data is base64, text data is plain text.
5
+ * Text files inline into the prompt body; images/PDFs become the same
6
+ * content parts the desktop composer submits ({type:'image'} /
7
+ * {type:'file'}), so provider media normalization treats them identically.
8
+ */
9
+
10
+ export const AUTOMATION_ATTACHMENT_KINDS = ['image', 'text', 'pdf'];
11
+ export const MAX_AUTOMATION_ATTACHMENTS = 8;
12
+ // Base64 total across image/pdf items; text totals separately.
13
+ export const MAX_AUTOMATION_BINARY_TOTAL = 8_000_000;
14
+ export const MAX_AUTOMATION_TEXT_TOTAL = 200_000;
15
+
16
+ /** Validate + strip a stored/list attachments value to the persisted shape (or null). */
17
+ export function normalizeAutomationAttachments(value) {
18
+ if (!Array.isArray(value) || value.length === 0) return null;
19
+ const out = [];
20
+ let binaryTotal = 0;
21
+ let textTotal = 0;
22
+ for (const entry of value.slice(0, MAX_AUTOMATION_ATTACHMENTS)) {
23
+ if (!entry || typeof entry !== 'object') continue;
24
+ const kind = String(entry.kind || '').trim();
25
+ const data = typeof entry.data === 'string' ? entry.data : '';
26
+ if (!AUTOMATION_ATTACHMENT_KINDS.includes(kind) || !data) continue;
27
+ if (kind === 'text') {
28
+ textTotal += data.length;
29
+ if (textTotal > MAX_AUTOMATION_TEXT_TOTAL) {
30
+ throw new Error('text attachments are too large together (200 KB max)');
31
+ }
32
+ } else {
33
+ binaryTotal += data.length;
34
+ if (binaryTotal > MAX_AUTOMATION_BINARY_TOTAL) {
35
+ throw new Error('image/PDF attachments are too large together (8 MB max)');
36
+ }
37
+ }
38
+ out.push({
39
+ kind,
40
+ name: String(entry.name || '').slice(0, 200) || `attachment-${out.length + 1}`,
41
+ mimeType: String(entry.mimeType || (kind === 'pdf' ? 'application/pdf' : kind === 'image' ? 'image/png' : 'text/plain')),
42
+ data,
43
+ });
44
+ }
45
+ return out.length ? out : null;
46
+ }
47
+
48
+ /**
49
+ * Build the askSession prompt content: plain string when there are no
50
+ * attachments, otherwise a parts array (text + image/file blocks).
51
+ */
52
+ export function automationPromptContent(promptText, attachments) {
53
+ const rows = Array.isArray(attachments) ? attachments : [];
54
+ let text = String(promptText || '');
55
+ const textFiles = rows.filter((entry) => entry?.kind === 'text' && entry.data);
56
+ if (textFiles.length) {
57
+ text += '\n\n' + textFiles
58
+ .map((entry) => `--- Attached file: ${entry.name} ---\n${entry.data}`)
59
+ .join('\n\n');
60
+ }
61
+ const parts = [];
62
+ for (const entry of rows) {
63
+ if (!entry || typeof entry.data !== 'string' || !entry.data) continue;
64
+ if (entry.kind === 'image') {
65
+ parts.push({ type: 'image', data: entry.data, mimeType: entry.mimeType || 'image/png' });
66
+ } else if (entry.kind === 'pdf') {
67
+ parts.push({ type: 'file', data: entry.data, mimeType: entry.mimeType || 'application/pdf', filename: entry.name });
68
+ }
69
+ }
70
+ if (parts.length === 0) return text;
71
+ return [{ type: 'text', text }, ...parts];
72
+ }