mixdog 0.9.66 → 0.9.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +20 -2
- package/src/runtime/channels/lib/config.mjs +13 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
- package/src/runtime/channels/lib/scheduler.mjs +7 -15
- package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
- package/src/runtime/channels/lib/webhook.mjs +46 -246
- package/src/runtime/channels/lib/worker-main.mjs +45 -92
- package/src/runtime/shared/automation-attachments.mjs +72 -0
- package/src/runtime/shared/automation-workflow.mjs +41 -0
- package/src/runtime/shared/config.mjs +0 -9
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/time-format.mjs +56 -0
- package/src/runtime/shared/tool-card-model.mjs +740 -0
- package/src/runtime/shared/webhook-session-run.mjs +57 -0
- package/src/runtime/shared/webhooks-db.mjs +19 -3
- package/src/session-runtime/channel-config-api.mjs +13 -1
- package/src/session-runtime/lifecycle-api.mjs +12 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/runtime-core.mjs +127 -22
- package/src/session-runtime/session-text.mjs +6 -0
- package/src/session-runtime/settings-api.mjs +0 -12
- package/src/standalone/channel-admin.mjs +71 -22
- package/src/standalone/channel-daemon-client.mjs +5 -1
- package/src/standalone/channel-daemon-transport.mjs +112 -20
- package/src/standalone/channel-daemon.mjs +6 -4
- package/src/tui/App.jsx +2 -47
- package/src/tui/app/channel-pickers.mjs +8 -173
- package/src/tui/app/doctor.mjs +0 -1
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/components/ToolExecution.jsx +47 -196
- package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
- package/src/tui/components/tool-execution/text-format.mjs +27 -104
- package/src/tui/dist/index.mjs +340 -346
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/session-api-ext.mjs +32 -16
- package/src/tui/engine.mjs +14 -1
- package/src/tui/time-format.mjs +4 -51
- package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
339
|
+
this.hookTunnel = startHookTunnel({ relayUrl, getLocalPort: () => this.boundPort });
|
|
340
|
+
logWebhook(`public hook base: ${this.hookTunnel.publicBase}`);
|
|
459
341
|
} catch (err) {
|
|
460
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
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
|
|
400
|
+
async _dispatchSessionRun(name, model, fullPrompt, headers, deliveryId, res, extra = {}) {
|
|
579
401
|
await updateDeliveryStatus(name, deliveryId, "processing");
|
|
580
|
-
//
|
|
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
|
|
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
|
-
|
|
588
|
-
preset: model,
|
|
409
|
+
model: model || null,
|
|
589
410
|
prompt: fullPrompt,
|
|
590
|
-
|
|
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}:
|
|
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}:
|
|
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: "
|
|
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
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
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 {
|
|
450
|
+
const { model, instructions, cwd, workflow, attachments, delivery } = endpoint;
|
|
626
451
|
const payloadContent = this._buildFencedPayload(body, headers);
|
|
627
|
-
if (
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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.
|
|
677
|
-
return
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
+
}
|