mixdog 0.9.67 → 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 +1 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- 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.mjs +23 -45
- 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/runtime-core.mjs +103 -17
- 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/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/dist/index.mjs +42 -169
- package/src/tui/engine/session-api-ext.mjs +32 -6
- package/src/tui/engine.mjs +14 -1
package/package.json
CHANGED
|
@@ -395,6 +395,9 @@ export function createSession(opts) {
|
|
|
395
395
|
// scheduler/daily-standup, webhook/github-push, lead/worker.
|
|
396
396
|
sourceType: opts.sourceType || null,
|
|
397
397
|
sourceName: opts.sourceName || null,
|
|
398
|
+
// Automation delivery mode ('app' | 'channel' | 'both'): the desktop
|
|
399
|
+
// sidebar hides channel-only runner sessions from Automations.
|
|
400
|
+
sourceDelivery: opts.sourceDelivery || null,
|
|
398
401
|
// Provider-scoped unified cache key — one shard per provider,
|
|
399
402
|
// shared across all roles / sources (agent/maintenance/mcp/
|
|
400
403
|
// scheduler/webhook). Role or source-specific context must be
|
|
@@ -137,6 +137,7 @@ export function _sessionSummary(session) {
|
|
|
137
137
|
agent: session.agent || null,
|
|
138
138
|
sourceType: session.sourceType || null,
|
|
139
139
|
sourceName: session.sourceName || null,
|
|
140
|
+
sourceDelivery: session.sourceDelivery || null,
|
|
140
141
|
scopeKey: session.scopeKey || null,
|
|
141
142
|
ownerSessionId: session.ownerSessionId || null,
|
|
142
143
|
clientHostPid: _positiveNumber(session.clientHostPid, 0) || null,
|
|
@@ -31,6 +31,7 @@ function isLeadVisibleRow(row) {
|
|
|
31
31
|
|| sourceType === 'lead'
|
|
32
32
|
|| sourceType === 'cli'
|
|
33
33
|
|| sourceType === 'schedule'
|
|
34
|
+
|| sourceType === 'webhook'
|
|
34
35
|
|| (!sourceType && !sourceName && owner !== 'agent');
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -83,6 +84,10 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
83
84
|
return {
|
|
84
85
|
id: row.id,
|
|
85
86
|
updatedAt: positiveNumber(row.updatedAt, 0),
|
|
87
|
+
// Conversation-activity timestamp (mirror of listLeadSessions):
|
|
88
|
+
// detach/resume bookkeeping bumps updatedAt in bulk on restarts, so
|
|
89
|
+
// Recent must order by lastUsedAt or every restart reshuffles rows.
|
|
90
|
+
lastUsedAt: positiveNumber(row.lastUsedAt, 0),
|
|
86
91
|
createdAt: positiveNumber(row.createdAt, 0),
|
|
87
92
|
lastHeartbeatAt: positiveNumber(row.lastHeartbeatAt, 0),
|
|
88
93
|
// Liveness comes from the .hb sidecar mtime alone: stored row fields
|
|
@@ -95,6 +100,7 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
95
100
|
agent: row.agent || null,
|
|
96
101
|
sourceType: row.sourceType || null,
|
|
97
102
|
sourceName: row.sourceName || null,
|
|
103
|
+
sourceDelivery: row.sourceDelivery || null,
|
|
98
104
|
scopeKey: row.scopeKey || null,
|
|
99
105
|
ownerSessionId: row.ownerSessionId || null,
|
|
100
106
|
clientHostPid: positiveNumber(row.clientHostPid, 0) || null,
|
|
@@ -163,7 +169,8 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes()) {
|
|
|
163
169
|
// cold catalog; the authoritative runtime can reconcile it later.
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
|
-
return rows.sort((left, right) =>
|
|
172
|
+
return rows.sort((left, right) =>
|
|
173
|
+
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
167
174
|
}
|
|
168
175
|
|
|
169
176
|
export function listStoredSessionSummaries(options = {}) {
|
|
@@ -176,7 +183,8 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
176
183
|
const rows = (Array.isArray(index.rows) ? index.rows : [])
|
|
177
184
|
.map((row) => normalizedRow(row, heartbeatMtimes.get(row?.id) || 0))
|
|
178
185
|
.filter((row) => row && isLeadVisibleRow(row))
|
|
179
|
-
.sort((left, right) =>
|
|
186
|
+
.sort((left, right) =>
|
|
187
|
+
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
180
188
|
if (rows.length > 0 || options.rebuildIfMissing === false) return rows;
|
|
181
189
|
}
|
|
182
190
|
} catch {
|
|
@@ -69,11 +69,12 @@ async function loadConfig({ freshSecrets = false } = {}) {
|
|
|
69
69
|
// are no longer read. Done one-shots are dropped so they never re-arm.
|
|
70
70
|
const scheduleEntries = (await listSchedules())
|
|
71
71
|
.filter((s) => s.enabled !== false && s.status !== "done");
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
raw.
|
|
72
|
+
// Every schedule fires as a visible session run (runScheduleSession)
|
|
73
|
+
// through the non-interactive dispatch path; `target` only decides
|
|
74
|
+
// whether the RESULT is relayed to the schedule's channel. The legacy
|
|
75
|
+
// interactive bucket (Lead-session inject) is retired.
|
|
76
|
+
raw.nonInteractive = scheduleEntries;
|
|
77
|
+
raw.interactive = [];
|
|
77
78
|
const accessChannels = { ...raw.access?.channels ?? {} };
|
|
78
79
|
// voice config lives at the top level of mixdog-config.json (peer of
|
|
79
80
|
// channels), so readSection("channels") never sees it. Pull it explicitly.
|
|
@@ -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.
|
|
@@ -397,24 +397,27 @@ ${headersSummary}
|
|
|
397
397
|
${payload}
|
|
398
398
|
<<<${_UNTRUSTED}_END>>>`;
|
|
399
399
|
}
|
|
400
|
-
async
|
|
400
|
+
async _dispatchSessionRun(name, model, fullPrompt, headers, deliveryId, res, extra = {}) {
|
|
401
401
|
await updateDeliveryStatus(name, deliveryId, "processing");
|
|
402
|
-
//
|
|
402
|
+
// Session dispatch must not be allowed to hang forever — without a
|
|
403
403
|
// ceiling a stuck LLM call leaves the delivery in `processing`
|
|
404
404
|
// for the lifetime of the process and dedup keeps re-running
|
|
405
|
-
// forever. 10 minutes covers the slowest
|
|
405
|
+
// forever. 10 minutes covers the slowest handler run we ship.
|
|
406
406
|
const DISPATCH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
407
407
|
let timeoutHandle = null;
|
|
408
408
|
const dispatchP = Promise.resolve(this.bridgeDispatch({
|
|
409
|
-
|
|
410
|
-
preset: model,
|
|
409
|
+
model: model || null,
|
|
411
410
|
prompt: fullPrompt,
|
|
412
|
-
|
|
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,
|
|
413
417
|
context: {
|
|
414
418
|
source: "webhook",
|
|
415
419
|
endpoint: name,
|
|
416
420
|
deliveryId,
|
|
417
|
-
channel: channel || null,
|
|
418
421
|
event: headers["x-github-event"] || null,
|
|
419
422
|
},
|
|
420
423
|
}));
|
|
@@ -427,54 +430,29 @@ ${payload}
|
|
|
427
430
|
Promise.race([dispatchP, timeoutP]).then(() => {
|
|
428
431
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
429
432
|
void updateDeliveryStatus(name, deliveryId, "done").catch((e) => logWebhook(`${name}: delivery status update failed: ${e?.message || e}`));
|
|
430
|
-
logWebhook(`${name}:
|
|
433
|
+
logWebhook(`${name}: webhook session run dispatched (id=${deliveryId})`);
|
|
431
434
|
}).catch((err) => {
|
|
432
435
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
433
436
|
void updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) }).catch(() => {});
|
|
434
|
-
logWebhook(`${name}:
|
|
437
|
+
logWebhook(`${name}: webhook session run failed: ${err?.message || err}`);
|
|
435
438
|
});
|
|
436
439
|
res.writeHead(202, { "Content-Type": "application/json" });
|
|
437
|
-
res.end(JSON.stringify({ status: "accepted", handler: "
|
|
440
|
+
res.end(JSON.stringify({ status: "accepted", handler: "session", id: deliveryId }));
|
|
438
441
|
}
|
|
439
442
|
async handleWebhook(name, body, headers, res, deliveryId, endpoint) {
|
|
440
|
-
// A PG endpoint row
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
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.
|
|
445
448
|
if (endpoint) {
|
|
446
449
|
try {
|
|
447
|
-
const {
|
|
450
|
+
const { model, instructions, cwd, workflow, attachments, delivery } = endpoint;
|
|
448
451
|
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 }));
|
|
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 });
|
|
478
456
|
return;
|
|
479
457
|
} catch (err) {
|
|
480
458
|
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
|
+
}
|