mixdog 0.9.67 → 0.9.69
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 +6 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
- package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
- package/src/runtime/channels/lib/config.mjs +6 -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/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
- package/src/runtime/channels/lib/webhook.mjs +36 -46
- 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/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- 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 +8 -1
- package/src/session-runtime/lifecycle-api.mjs +7 -0
- package/src/session-runtime/memory-daemon-probe.mjs +61 -0
- package/src/session-runtime/model-capabilities.mjs +6 -4
- package/src/session-runtime/notification-bus.mjs +82 -0
- package/src/session-runtime/provider-auth-api.mjs +16 -1
- package/src/session-runtime/provider-usage.mjs +3 -0
- package/src/session-runtime/remote-control.mjs +166 -0
- package/src/session-runtime/remote-transcript.mjs +126 -0
- package/src/session-runtime/remote-transition-queue.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +184 -660
- package/src/session-runtime/runtime-tunables.mjs +57 -0
- package/src/session-runtime/self-update.mjs +129 -0
- package/src/session-runtime/skills-api.mjs +77 -0
- package/src/session-runtime/tool-surface.mjs +83 -0
- package/src/session-runtime/workflow-agents-api.mjs +206 -3
- package/src/session-runtime/workflow.mjs +84 -17
- package/src/standalone/agent-tool/worker-index.mjs +287 -0
- package/src/standalone/agent-tool.mjs +22 -346
- package/src/standalone/agent-watchdog-registry.mjs +101 -0
- package/src/standalone/channel-admin.mjs +36 -1
- 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/standalone/channel-worker.mjs +7 -13
- package/src/tui/App.jsx +2 -32
- package/src/tui/app/channel-pickers.mjs +2 -143
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/components/StatusLine.jsx +6 -5
- package/src/tui/dist/index.mjs +162 -259
- package/src/tui/engine/labels.mjs +0 -8
- package/src/tui/engine/session-api-ext.mjs +45 -10
- package/src/tui/engine/turn-watchdog.mjs +92 -0
- package/src/tui/engine/turn.mjs +18 -70
- package/src/tui/engine.mjs +14 -1
- package/src/workflows/default/WORKFLOW.md +1 -1
- package/src/workflows/solo/WORKFLOW.md +1 -1
|
@@ -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
|
-
|
|
276
|
-
|
|
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'
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
const channelId =
|
|
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
|
-
|
|
574
|
-
|
|
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
|
);
|
|
@@ -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
|
-
|
|
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.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// of the box: no binary, no authtoken, no reserved domain.
|
|
10
10
|
import * as http from "http";
|
|
11
11
|
import { randomBytes, randomUUID } from "crypto";
|
|
12
|
-
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import WebSocket from "ws";
|
|
15
15
|
import { DATA_DIR } from "../config.mjs";
|
|
@@ -41,13 +41,17 @@ export function loadOrCreateHookIdentity() {
|
|
|
41
41
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
42
42
|
if (typeof parsed.deviceId === "string" && /^[0-9a-f-]{8,64}$/.test(parsed.deviceId)
|
|
43
43
|
&& typeof parsed.deviceSecret === "string" && parsed.deviceSecret.length >= 16) {
|
|
44
|
+
// The secret authenticates the webhook tunnel leg (whoever holds it can
|
|
45
|
+
// take over inbound deliveries), so clamp legacy world-readable files.
|
|
46
|
+
try { chmodSync(path, 0o600); } catch { /* windows/fs quirk */ }
|
|
44
47
|
return { deviceId: parsed.deviceId, deviceSecret: parsed.deviceSecret };
|
|
45
48
|
}
|
|
46
49
|
} catch { /* first run */ }
|
|
47
50
|
const identity = { deviceId: randomUUID(), deviceSecret: randomBytes(24).toString("hex") };
|
|
48
51
|
try {
|
|
49
52
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
50
|
-
writeFileSync(path, JSON.stringify(identity, null, 2), "utf8");
|
|
53
|
+
writeFileSync(path, JSON.stringify(identity, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
54
|
+
try { chmodSync(path, 0o600); } catch { /* windows/fs quirk */ }
|
|
51
55
|
} catch (err) {
|
|
52
56
|
logWebhook(`hook tunnel: identity persist failed — ${err?.message || err}`);
|
|
53
57
|
}
|
|
@@ -283,9 +283,21 @@ class WebhookServer {
|
|
|
283
283
|
res.end(JSON.stringify({ error: "webhook secret required for endpoint" }));
|
|
284
284
|
return false;
|
|
285
285
|
}
|
|
286
|
+
// Config-file endpoints used to accept unsigned bodies with a warning. The
|
|
287
|
+
// payload reaches an agent, so unauthenticated input now needs an explicit
|
|
288
|
+
// opt-in (endpoint `allowUnsigned: true`, or config.allowUnsignedWebhooks).
|
|
289
|
+
const allowUnsigned = endpoint?.allowUnsigned === true
|
|
290
|
+
|| this.config.endpoints?.[name]?.allowUnsigned === true
|
|
291
|
+
|| this.config.allowUnsignedWebhooks === true;
|
|
292
|
+
if (!allowUnsigned) {
|
|
293
|
+
logWebhook(`${name}: rejected — unsigned request and no secret configured`);
|
|
294
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
295
|
+
res.end(JSON.stringify({ error: "webhook secret required for endpoint" }));
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
286
298
|
if (!this.noSecretWarned) {
|
|
287
299
|
this.noSecretWarned = true;
|
|
288
|
-
logWebhook(`warning \u2014
|
|
300
|
+
logWebhook(`warning \u2014 unsigned webhooks explicitly allowed, skipping signature verification`);
|
|
289
301
|
}
|
|
290
302
|
return true;
|
|
291
303
|
}
|
|
@@ -397,24 +409,27 @@ ${headersSummary}
|
|
|
397
409
|
${payload}
|
|
398
410
|
<<<${_UNTRUSTED}_END>>>`;
|
|
399
411
|
}
|
|
400
|
-
async
|
|
412
|
+
async _dispatchSessionRun(name, model, fullPrompt, headers, deliveryId, res, extra = {}) {
|
|
401
413
|
await updateDeliveryStatus(name, deliveryId, "processing");
|
|
402
|
-
//
|
|
414
|
+
// Session dispatch must not be allowed to hang forever — without a
|
|
403
415
|
// ceiling a stuck LLM call leaves the delivery in `processing`
|
|
404
416
|
// for the lifetime of the process and dedup keeps re-running
|
|
405
|
-
// forever. 10 minutes covers the slowest
|
|
417
|
+
// forever. 10 minutes covers the slowest handler run we ship.
|
|
406
418
|
const DISPATCH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
407
419
|
let timeoutHandle = null;
|
|
408
420
|
const dispatchP = Promise.resolve(this.bridgeDispatch({
|
|
409
|
-
|
|
410
|
-
preset: model,
|
|
421
|
+
model: model || null,
|
|
411
422
|
prompt: fullPrompt,
|
|
412
|
-
|
|
423
|
+
// Endpoint-scoped project/workflow (New-task parity): the webhook row's
|
|
424
|
+
// cwd/workflow define the created session, not the worker's global cwd.
|
|
425
|
+
cwd: extra.cwd || this.config?.cwd || null,
|
|
426
|
+
workflow: extra.workflow || null,
|
|
427
|
+
attachments: extra.attachments || null,
|
|
428
|
+
delivery: extra.delivery || null,
|
|
413
429
|
context: {
|
|
414
430
|
source: "webhook",
|
|
415
431
|
endpoint: name,
|
|
416
432
|
deliveryId,
|
|
417
|
-
channel: channel || null,
|
|
418
433
|
event: headers["x-github-event"] || null,
|
|
419
434
|
},
|
|
420
435
|
}));
|
|
@@ -427,54 +442,29 @@ ${payload}
|
|
|
427
442
|
Promise.race([dispatchP, timeoutP]).then(() => {
|
|
428
443
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
429
444
|
void updateDeliveryStatus(name, deliveryId, "done").catch((e) => logWebhook(`${name}: delivery status update failed: ${e?.message || e}`));
|
|
430
|
-
logWebhook(`${name}:
|
|
445
|
+
logWebhook(`${name}: webhook session run dispatched (id=${deliveryId})`);
|
|
431
446
|
}).catch((err) => {
|
|
432
447
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
433
448
|
void updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) }).catch(() => {});
|
|
434
|
-
logWebhook(`${name}:
|
|
449
|
+
logWebhook(`${name}: webhook session run failed: ${err?.message || err}`);
|
|
435
450
|
});
|
|
436
451
|
res.writeHead(202, { "Content-Type": "application/json" });
|
|
437
|
-
res.end(JSON.stringify({ status: "accepted", handler: "
|
|
452
|
+
res.end(JSON.stringify({ status: "accepted", handler: "session", id: deliveryId }));
|
|
438
453
|
}
|
|
439
454
|
async handleWebhook(name, body, headers, res, deliveryId, endpoint) {
|
|
440
|
-
// A PG endpoint row
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
455
|
+
// A PG endpoint row runs as a VISIBLE webhook session (user decision):
|
|
456
|
+
// no Lead injection, no channel delivery — the run lands in Recent like
|
|
457
|
+
// a schedule run, and its session content IS the result surface.
|
|
458
|
+
// Parser-only endpoints (no table row) fall through to the event
|
|
459
|
+
// pipeline below.
|
|
445
460
|
if (endpoint) {
|
|
446
461
|
try {
|
|
447
|
-
const {
|
|
462
|
+
const { model, instructions, cwd, workflow, attachments, delivery } = endpoint;
|
|
448
463
|
const payloadContent = this._buildFencedPayload(body, headers);
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
454
|
-
res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires role" }));
|
|
455
|
-
return;
|
|
456
|
-
} else if (!model) {
|
|
457
|
-
await updateDeliveryStatus(name, deliveryId, "failed", { error: "delegate mode requires model" });
|
|
458
|
-
logWebhook(`${name}: delegate mode requires model - rejected`);
|
|
459
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
460
|
-
res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires model" }));
|
|
461
|
-
return;
|
|
462
|
-
} else if (!this.bridgeDispatch) {
|
|
463
|
-
throw new Error(`[webhook] delegate mode requires bridgeDispatch`);
|
|
464
|
-
} else {
|
|
465
|
-
const fullPrompt = `${instructions}\n\n${payloadContent}`;
|
|
466
|
-
await this._dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channelId);
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
if (this.eventPipeline) {
|
|
471
|
-
await updateDeliveryStatus(name, deliveryId, "processing");
|
|
472
|
-
this.eventPipeline.enqueueDirect(name, payloadContent, channelId, "interactive", instructions);
|
|
473
|
-
await updateDeliveryStatus(name, deliveryId, "done");
|
|
474
|
-
logWebhook(`${name}: interactive enqueued (id=${deliveryId})`);
|
|
475
|
-
}
|
|
476
|
-
res.writeHead(202, { "Content-Type": "application/json" });
|
|
477
|
-
res.end(JSON.stringify({ status: "accepted", handler: "interactive", id: deliveryId }));
|
|
464
|
+
if (!this.bridgeDispatch) throw new Error(`[webhook] session dispatch requires bridgeDispatch`);
|
|
465
|
+
const fullPrompt = `${instructions}\n\n${payloadContent}`;
|
|
466
|
+
await this._dispatchSessionRun(name, model || null, fullPrompt, headers, deliveryId, res,
|
|
467
|
+
{ cwd: cwd || null, workflow: workflow || null, attachments: attachments || null, delivery: delivery || null });
|
|
478
468
|
return;
|
|
479
469
|
} catch (err) {
|
|
480
470
|
await updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) });
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a stored automation workflow id (schedule/webhook row) into
|
|
3
|
+
* createSession options: the workflow summary meta plus the WORKFLOW.md
|
|
4
|
+
* context block, exactly like a desktop New task session gets for the
|
|
5
|
+
* active workflow. An empty/unknown id resolves to the default pack via
|
|
6
|
+
* loadWorkflowPack's fallback; resolution failures degrade to no workflow
|
|
7
|
+
* context (the session still runs with the base lead ruleset).
|
|
8
|
+
*/
|
|
9
|
+
import { createWorkflowHelpers } from '../../session-runtime/workflow.mjs';
|
|
10
|
+
import { STANDALONE_ROOT, STANDALONE_DATA_DIR } from '../../session-runtime/runtime-paths.mjs';
|
|
11
|
+
import { readMarkdownDocument, normalizeAgentPermissionOrNone } from './markdown-frontmatter.mjs';
|
|
12
|
+
|
|
13
|
+
let _helpers = null;
|
|
14
|
+
function helpers() {
|
|
15
|
+
if (!_helpers) {
|
|
16
|
+
_helpers = createWorkflowHelpers({
|
|
17
|
+
rootDir: STANDALONE_ROOT,
|
|
18
|
+
dataDir: STANDALONE_DATA_DIR,
|
|
19
|
+
readMarkdownDocument,
|
|
20
|
+
normalizeAgentPermissionOrNone,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return _helpers;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** { workflow, workflowContext } createSession opts for a workflow id, or {} when unset/unresolvable. */
|
|
27
|
+
export function automationWorkflowOpts(workflowId) {
|
|
28
|
+
const id = String(workflowId || '').trim();
|
|
29
|
+
if (!id) return {};
|
|
30
|
+
try {
|
|
31
|
+
const h = helpers();
|
|
32
|
+
const pack = h.loadWorkflowPack(undefined, id);
|
|
33
|
+
if (!pack) return {};
|
|
34
|
+
return {
|
|
35
|
+
workflow: h.workflowSummary(pack),
|
|
36
|
+
workflowContext: h.workflowContextBlock({ workflow: { active: id } }, undefined),
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|