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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  3. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  4. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  5. package/src/runtime/channels/lib/config.mjs +6 -5
  6. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  7. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  8. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  9. package/src/runtime/channels/lib/webhook.mjs +23 -45
  10. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  11. package/src/runtime/shared/automation-attachments.mjs +72 -0
  12. package/src/runtime/shared/automation-workflow.mjs +41 -0
  13. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  14. package/src/runtime/shared/schedules-db.mjs +18 -3
  15. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  16. package/src/runtime/shared/webhooks-db.mjs +19 -3
  17. package/src/session-runtime/channel-config-api.mjs +8 -1
  18. package/src/session-runtime/lifecycle-api.mjs +7 -0
  19. package/src/session-runtime/runtime-core.mjs +103 -17
  20. package/src/standalone/channel-admin.mjs +36 -1
  21. package/src/standalone/channel-daemon-client.mjs +5 -1
  22. package/src/standalone/channel-daemon-transport.mjs +112 -20
  23. package/src/standalone/channel-daemon.mjs +6 -4
  24. package/src/tui/App.jsx +2 -32
  25. package/src/tui/app/channel-pickers.mjs +2 -143
  26. package/src/tui/app/slash-commands.mjs +1 -3
  27. package/src/tui/app/slash-dispatch.mjs +3 -3
  28. package/src/tui/dist/index.mjs +42 -169
  29. package/src/tui/engine/session-api-ext.mjs +32 -6
  30. package/src/tui/engine.mjs +14 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.67",
3
+ "version": "0.9.68",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -395,6 +395,9 @@ export function createSession(opts) {
395
395
  // scheduler/daily-standup, webhook/github-push, lead/worker.
396
396
  sourceType: opts.sourceType || null,
397
397
  sourceName: opts.sourceName || null,
398
+ // Automation delivery mode ('app' | 'channel' | 'both'): the desktop
399
+ // sidebar hides channel-only runner sessions from Automations.
400
+ sourceDelivery: opts.sourceDelivery || null,
398
401
  // Provider-scoped unified cache key — one shard per provider,
399
402
  // shared across all roles / sources (agent/maintenance/mcp/
400
403
  // scheduler/webhook). Role or source-specific context must be
@@ -137,6 +137,7 @@ export function _sessionSummary(session) {
137
137
  agent: session.agent || null,
138
138
  sourceType: session.sourceType || null,
139
139
  sourceName: session.sourceName || null,
140
+ sourceDelivery: session.sourceDelivery || null,
140
141
  scopeKey: session.scopeKey || null,
141
142
  ownerSessionId: session.ownerSessionId || null,
142
143
  clientHostPid: _positiveNumber(session.clientHostPid, 0) || null,
@@ -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) => (right.updatedAt || 0) - (left.updatedAt || 0));
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) => (right.updatedAt || 0) - (left.updatedAt || 0));
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
- // `target` drives routing: 'channel' non-interactive dispatch to the
73
- // schedule's channel_id; 'session' inject into the current (Lead)
74
- // session. Mirrors the webhook model.
75
- raw.nonInteractive = scheduleEntries.filter((s) => s.target === "channel");
76
- raw.interactive = scheduleEntries.filter((s) => s.target === "session");
72
+ // Every schedule fires as a visible session run (runScheduleSession)
73
+ // through the non-interactive dispatch path; `target` only decides
74
+ // whether the RESULT is relayed to the schedule's channel. The legacy
75
+ // interactive bucket (Lead-session inject) is retired.
76
+ raw.nonInteractive = scheduleEntries;
77
+ raw.interactive = [];
77
78
  const accessChannels = { ...raw.access?.channels ?? {} };
78
79
  // voice config lives at the top level of mixdog-config.json (peer of
79
80
  // channels), so readSection("channels") never sees it. Pull it explicitly.
@@ -48,6 +48,11 @@ export function createOwnedRuntime({
48
48
  let _ownedRuntimeStopRequested = false;
49
49
  let bridgeOwnershipRefreshInFlight = null;
50
50
  let _memoryDrainTimer = null;
51
+ // Automation (scheduler + webhook server + relay tunnel) can outlive a
52
+ // failed/absent messaging backend: it is tracked separately so teardown and
53
+ // reload know it is running even while bridgeRuntimeConnected stays false.
54
+ let automationRunning = false;
55
+ let automationStartPromise = null;
51
56
  // Promise that resolves when the current startOwnedRuntime() run fully
52
57
  // settles (bridgeRuntimeStarting -> false). reloadRuntimeConfig awaits this
53
58
  // before issuing a restart so a backend swap that lands mid-start is not
@@ -133,6 +138,34 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
133
138
  setWebhookServer(null);
134
139
  }
135
140
  }
141
+ async function startAutomationRuntime() {
142
+ if (automationRunning) {
143
+ syncOwnedWebhookAndEventRuntime();
144
+ return;
145
+ }
146
+ if (automationStartPromise) return automationStartPromise;
147
+ if (!bridgeRuntimeStarting) _ownedRuntimeStopRequested = false;
148
+ const run = (async () => {
149
+ try {
150
+ const agentCfg = loadAgentConfig();
151
+ await initProviders(agentCfg.providers || {});
152
+ } catch (e) {
153
+ process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
154
+ }
155
+ if (_ownedRuntimeStopRequested) return;
156
+ scheduler.start();
157
+ startSnapshotWriter(scheduler);
158
+ syncOwnedWebhookAndEventRuntime();
159
+ automationRunning = true;
160
+ process.stderr.write('mixdog: automation runtime up without messaging backend\n');
161
+ })();
162
+ automationStartPromise = run;
163
+ try {
164
+ return await run;
165
+ } finally {
166
+ if (automationStartPromise === run) automationStartPromise = null;
167
+ }
168
+ }
136
169
  let _ownedRuntimeSelfHealing = false;
137
170
  // Explicit restore path for an already-connected owner. The 3s ownership tick
138
171
  // must not run this implicitly: re-binding status on every tick can move the
@@ -272,9 +305,14 @@ async function startOwnedRuntime(options = {}) {
272
305
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
273
306
  return;
274
307
  }
275
- scheduler.start();
276
- startSnapshotWriter(scheduler);
308
+ // Reconnect after a degraded (automation-only) start must not double-arm
309
+ // the scheduler/snapshot timers; the webhook/event sync is idempotent.
310
+ if (!automationRunning) {
311
+ scheduler.start();
312
+ startSnapshotWriter(scheduler);
313
+ }
277
314
  syncOwnedWebhookAndEventRuntime();
315
+ automationRunning = true;
278
316
  if (restoreBinding) {
279
317
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
280
318
  const pendingTranscriptPath = forwarder.transcriptPath;
@@ -303,6 +341,23 @@ async function startOwnedRuntime(options = {}) {
303
341
  try { releaseOwnedChannelLocks(instanceId); } catch {}
304
342
  try { clearActiveInstance(instanceId); } catch {}
305
343
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
344
+ // DEGRADED MODE (automation decoupling): a messaging connect failure —
345
+ // packaged runtime without discord.js, bad token, gateway outage — must
346
+ // not take schedules/webhooks down with it. Start the automation runtime
347
+ // anyway; sends stay dead (bridgeRuntimeConnected=false) but session
348
+ // runs, the webhook server, and the relay tunnel work.
349
+ if (!_ownedRuntimeStopRequested) {
350
+ if (automationRunning) {
351
+ // Already degraded-started by an earlier attempt; this run was only a
352
+ // messaging reconnect retry — the finally below settles the start.
353
+ return;
354
+ }
355
+ try {
356
+ await startAutomationRuntime();
357
+ } catch (e3) {
358
+ process.stderr.write(`mixdog: degraded automation start failed: ${e3 instanceof Error ? e3.message : String(e3)}\n`);
359
+ }
360
+ }
306
361
  } finally {
307
362
  settleInFlightStart();
308
363
  }
@@ -318,12 +373,15 @@ async function stopOwnedRuntime(reason) {
318
373
  // during that window (bridgeRuntimeStarting=true, getBridgeRuntimeConnected()
319
374
  // still false) we still need to tear that partial state down — otherwise
320
375
  // the port stays bound and active-instance.json stays stale.
321
- if (!getBridgeRuntimeConnected() && !bridgeRuntimeStarting) return;
376
+ if (!getBridgeRuntimeConnected() && !bridgeRuntimeStarting && !automationRunning && !automationStartPromise) return;
322
377
  // If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
323
378
  // startOwnedRuntime() to abort right after its getBackend().connect() resolves.
324
379
  // Without this the in-flight start re-marks connected and re-launches
325
380
  // scheduler/webhook/heartbeat after we tear them down here.
326
- if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
381
+ if (bridgeRuntimeStarting || automationStartPromise) _ownedRuntimeStopRequested = true;
382
+ if (automationStartPromise) {
383
+ try { await automationStartPromise; } catch {}
384
+ }
327
385
  const wasConnected = getBridgeRuntimeConnected();
328
386
  stopServerTyping();
329
387
  // Release the transcript fs.watch handle plus the forwarder's debounce/retry
@@ -335,6 +393,7 @@ async function stopOwnedRuntime(reason) {
335
393
  scheduler.stop();
336
394
  stopSnapshotWriter();
337
395
  await stopWebhookAndEventRuntime();
396
+ automationRunning = false;
338
397
  releaseOwnedChannelLocks(instanceId);
339
398
  clearActiveInstance(instanceId);
340
399
  try {
@@ -462,13 +521,14 @@ async function reloadRuntimeConfig() {
462
521
  } else if (nextBackend !== previousBackend) {
463
522
  try { await nextBackend.disconnect?.(); } catch {}
464
523
  }
465
- if (getBridgeRuntimeConnected()) {
524
+ if (getBridgeRuntimeConnected() || automationRunning) {
466
525
  syncOwnedWebhookAndEventRuntime({ reload: true });
467
526
  } else {
468
527
  await stopWebhookAndEventRuntime();
469
528
  }
470
529
  }
471
530
  return {
531
+ startAutomationRuntime,
472
532
  startOwnedRuntime,
473
533
  stopOwnedRuntime,
474
534
  refreshBridgeOwnership,
@@ -558,10 +558,10 @@ ${Scheduler.INSTANCE_UUID}`;
558
558
  `);
559
559
  return false;
560
560
  }
561
- // target 'channel' (non-interactive) dispatch to the schedule's
562
- // channel_id, falling back to the resolved main channel. target 'session'
563
- // (interactive) inject into the Lead session with no channel id.
564
- const channelId = type === "non-interactive"
561
+ // target 'channel' → relay the run result to the schedule's channel_id
562
+ // (falling back to the resolved main channel). target 'session' → no
563
+ // channel relay; the visible session run IS the surface.
564
+ const channelId = schedule.target === "channel"
565
565
  ? this.resolveChannel(schedule.channelId)
566
566
  : "";
567
567
  return await this.fireTimedPrompt(schedule, type, prompt, channelId, opts);
@@ -570,16 +570,8 @@ ${Scheduler.INSTANCE_UUID}`;
570
570
  async fireTimedPrompt(schedule, type, prompt, channelId, { awaitDispatch = false } = {}) {
571
571
  logSchedule(`firing ${schedule.name} (${type})
572
572
  `);
573
- if (type === "interactive") {
574
- if (this.injectFn) {
575
- this.injectFn(channelId, schedule.name, " ", {
576
- instruction: prompt,
577
- type: "schedule"
578
- });
579
- return true;
580
- }
581
- return false;
582
- }
573
+ // Legacy interactive Lead-session inject is retired: every schedule fire
574
+ // runs as its own visible session below (New-task parity, user decision).
583
575
  if (this.running.has(schedule.name)) return false;
584
576
  this.running.add(schedule.name);
585
577
  const presetId = schedule.model;
@@ -598,7 +590,7 @@ ${Scheduler.INSTANCE_UUID}`;
598
590
  const dispatch = runScheduleSession(schedule, { prompt })
599
591
  .then(({ result }) => {
600
592
  this.running.delete(schedule.name);
601
- if (result && this.sendFn) {
593
+ if (result && channelId && this.sendFn) {
602
594
  this.sendFn(channelId, result).catch(
603
595
  (err) => process.stderr.write(`mixdog scheduler: ${schedule.name} relay failed: ${err}\n`)
604
596
  );
@@ -27,6 +27,7 @@ function createToolDispatch({
27
27
  refreshBridgeOwnership,
28
28
  bindPersistedTranscriptIfAny,
29
29
  rebindCurrentTranscript,
30
+ startChannelBridge,
30
31
  stopOwnedRuntime,
31
32
  reloadRuntimeConfig,
32
33
  } = lifecycle;
@@ -60,7 +61,11 @@ function createToolDispatch({
60
61
  notifyRemoteAcquired?.();
61
62
  }
62
63
  try {
63
- await refreshBridgeOwnership({ restoreBinding: true });
64
+ if (typeof startChannelBridge === 'function') {
65
+ await startChannelBridge();
66
+ } else {
67
+ await refreshBridgeOwnership({ restoreBinding: true });
68
+ }
64
69
  // An already-connected owner returns early from
65
70
  // startOwnedRuntime(), so rebind explicitly to follow the
66
71
  // current (parent-chain) session transcript.
@@ -397,24 +397,27 @@ ${headersSummary}
397
397
  ${payload}
398
398
  <<<${_UNTRUSTED}_END>>>`;
399
399
  }
400
- async _dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channel) {
400
+ async _dispatchSessionRun(name, model, fullPrompt, headers, deliveryId, res, extra = {}) {
401
401
  await updateDeliveryStatus(name, deliveryId, "processing");
402
- // Bridge dispatch must not be allowed to hang forever — without a
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 delegate task we ship.
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
- role,
410
- preset: model,
409
+ model: model || null,
411
410
  prompt: fullPrompt,
412
- cwd: this.config?.cwd,
411
+ // Endpoint-scoped project/workflow (New-task parity): the webhook row's
412
+ // cwd/workflow define the created session, not the worker's global cwd.
413
+ cwd: extra.cwd || this.config?.cwd || null,
414
+ workflow: extra.workflow || null,
415
+ attachments: extra.attachments || null,
416
+ delivery: extra.delivery || null,
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}: delegate dispatched to bridge (role=${role}, id=${deliveryId})`);
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}: delegate dispatch failed: ${err?.message || err}`);
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: "delegate", id: deliveryId }));
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 is the folder-handler equivalent: routing is by
441
- // channel_id presence WITH a channel delegate dispatch to the row's
442
- // role/model and report to that channel; WITHOUT interactive enqueue
443
- // into the current (Lead) session. Parser-only endpoints (no table row)
444
- // fall through to the event pipeline below.
443
+ // A PG endpoint row runs as a VISIBLE webhook session (user decision):
444
+ // no Lead injection, no channel delivery the run lands in Recent like
445
+ // a schedule run, and its session content IS the result surface.
446
+ // Parser-only endpoints (no table row) fall through to the event
447
+ // pipeline below.
445
448
  if (endpoint) {
446
449
  try {
447
- const { channelId, role, model, instructions } = endpoint;
450
+ const { model, instructions, cwd, workflow, attachments, delivery } = endpoint;
448
451
  const payloadContent = this._buildFencedPayload(body, headers);
449
- if (channelId) {
450
- if (!role) {
451
- await updateDeliveryStatus(name, deliveryId, "failed", { error: "delegate mode requires role" });
452
- logWebhook(`${name}: delegate mode requires role - rejected`);
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
- webhookServer.setBridgeDispatch(async ({ role, preset, prompt, cwd, context }) => {
460
- // Delegate-mode webhookbridge orchestrator. Each bridge progress /
461
- // final event is forwarded to the Lead via the same channel-notify
462
- // path used by schedule & event-queue (injectAndRecord). Silent
463
- // lifecycle pings keep routing only to Discord.
464
- const [{ createStandaloneAgent }, cfgMod, reg, mgr] = await Promise.all([
465
- import("../../../standalone/agent-tool.mjs"),
466
- import("../../agent/orchestrator/config.mjs"),
467
- import("../../agent/orchestrator/providers/registry.mjs"),
468
- import("../../agent/orchestrator/session/manager.mjs"),
469
- ]);
470
- const agentTool = createStandaloneAgent({
471
- cfgMod,
472
- reg,
473
- mgr,
474
- dataDir: cfgMod.getPluginData(),
475
- cwd,
460
+ // Webhook fires run as sessions (schedules parity). Delivery mode:
461
+ // 'app' (default)the Automations session row IS the surface;
462
+ // 'channel'/'both' the run result also relays to the main channel.
463
+ webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context }) => {
464
+ const { runWebhookSession } = await import("../../shared/webhook-session-run.mjs");
465
+ const run = await runWebhookSession({
466
+ name: context?.endpoint || "webhook",
467
+ model: model || null,
468
+ cwd: cwd || null,
469
+ workflow: workflow || null,
470
+ attachments: attachments || null,
471
+ delivery: delivery || null,
472
+ prompt,
476
473
  });
477
- const channelId = resolveWebhookChannelId(context?.channel);
478
- const endpoint = context?.endpoint || "unknown";
479
- const event = context?.event || null;
480
- const deliveryId = context?.deliveryId || null;
481
- const label = `webhook:${endpoint}`;
482
- const instruction = `Webhook review from role=${role} on endpoint "${endpoint}"`
483
- + (event ? ` (event=${event})` : "")
484
- + (deliveryId ? ` (delivery=${deliveryId})` : "")
485
- + ". Relay the finding to the user naturally — summarize clearly, call out any issues, and note what needs a decision.";
486
- const notifyFn = (text, meta = {}) => {
487
- if (!text) return;
488
- // Webhook skip protocol: when the agent worker emits a `[meta:silent]`
489
- // marker (optionally behind model/role tag prefixes), the event is a
490
- // no-op (label-only, dedup, "nothing to report"). Drop the message
491
- // entirely — neither Lead inject nor Discord forward — instead of the
492
- // partial `silent_to_agent` semantics that still audit to Discord.
493
- const raw = String(text);
494
- if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
495
- // Deterministic findings-count drop. Code-review handlers emit a
496
- // structured `[[findings:N]]` token (N = number of issues). The RELAY —
497
- // not the worker's prose — decides: N==0 => clean review, drop entirely
498
- // (no Lead inject, no Discord forward). Token absent => fail-safe forward
499
- // so a real finding is never silently dropped if the worker omits it.
500
- const fc = raw.match(/\[\[findings:(\d+)\]\]/i);
501
- if (fc && Number(fc[1]) === 0) return;
502
- // Lifecycle pings (started / iter echoes, marked silent_to_agent) are
503
- // channel noise for an automated webhook review — drop them entirely so
504
- // a skip stays fully silent and only the final answer reaches the
505
- // channel. The final [meta:silent] skip result is already dropped above.
506
- if (meta?.silent_to_agent === true) return;
507
- // Strip the verdict token before surfacing (findings present, N>0).
508
- const surfaced = raw.replace(/\[\[findings:\d+\]\]/gi, "").replace(/[^\S\n]{2,}/g, " ").trim();
509
- injectAndRecord(channelId, label, surfaced || raw, {
510
- type: "webhook",
511
- instruction,
512
- });
513
- };
514
- // Per-terminal cwd under the daemon's single channels worker. A webhook
515
- // result is injected to ownerConn() — the connection whose session.leadPid
516
- // equals active-instance ownerLeadPid — so the worker must run in THAT
517
- // owner terminal's cwd. Read the sentinel keyed by ownerLeadPid; cwd-tool
518
- // writes session-cwd-<leadPid>.txt per connection, so write and read meet
519
- // on the same leadPid key no matter which terminal holds the owner seat.
520
- // Falls back to the session entry position; never the plugin CACHE root.
521
- const ownerPid = getActiveOwnerPid(readActiveInstance());
522
- const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
523
- return agentTool.execute(
524
- { type: "spawn", agent: role, preset, prompt, cwd: cwd || ownerCwd },
525
- { callerCwd: cwd || ownerCwd, invocationSource: "webhook", notifyFn },
526
- );
474
+ const mode = String(delivery || "app");
475
+ if ((mode === "channel" || mode === "both") && run?.result && backend?.sendMessage) {
476
+ const target = scheduler.resolveChannel("");
477
+ if (target) {
478
+ void bindingReady.then(() => backend.sendMessage(target, run.result))
479
+ .catch((err) => dropTrace("send.webhook.err", { channelId: target, err: String(err) }));
480
+ }
481
+ }
482
+ return run;
527
483
  });
528
484
  }
529
- function resolveWebhookChannelId(channelFlag) {
530
- // Single main channel: the endpoint's `channel` frontmatter is a boolean-ish
531
- // flag (any non-empty value except "false" → post to the main channel). A raw
532
- // channel id is still honored verbatim for owner-authored overrides.
533
- const main = config?.channelId || "";
534
- if (channelFlag == null) return main;
535
- const v = String(channelFlag).trim();
536
- if (v === "" || v.toLowerCase() === "false") return "";
537
- // A pure-digit / snowflake-looking value is treated as an explicit id;
538
- // anything else (legacy labels like "main") maps to the main channel.
539
- return /^-?\d+$/.test(v) ? v : main;
540
- }
541
485
  function wireEventQueueHandlers(eventQueue) {
542
486
  if (!eventQueue) return;
543
487
  eventQueue.setInjectHandler((channelId, name, content, options) => {
@@ -669,6 +613,7 @@ const {
669
613
  recoverUnsyncedTail: true,
670
614
  });
671
615
  },
616
+ startChannelBridge: () => start({ messaging: true }),
672
617
  stopOwnedRuntime,
673
618
  reloadRuntimeConfig,
674
619
  },
@@ -707,7 +652,24 @@ async function init(_sharedMcp) {
707
652
  injectAndRecord(channelId, name, content, options);
708
653
  });
709
654
  }
710
- async function start() {
655
+ function ensureConfigWatcher() {
656
+ if (_configWatcher) return;
657
+ try {
658
+ _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
659
+ invalidateConfigReadCache();
660
+ if (_reloadDebounce) clearTimeout(_reloadDebounce);
661
+ _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
662
+ });
663
+ } catch {}
664
+ }
665
+ async function start(options = {}) {
666
+ if (options?.messaging === false) {
667
+ channelBridgeActive = false;
668
+ writeBridgeState(false);
669
+ await startAutomationRuntime();
670
+ ensureConfigWatcher();
671
+ return;
672
+ }
711
673
  channelBridgeActive = true;
712
674
  writeBridgeState(true);
713
675
  // Daemon model: this runtime is the machine-global singleton bridge owner
@@ -729,18 +691,9 @@ async function start() {
729
691
  // ownership timer — the singleton daemon guarantees exactly one owner.
730
692
  armBridgeOwnershipTimer();
731
693
  // Hot-reload config on file change (schedules/webhooks/events).
732
- if (!_configWatcher) {
733
- try {
734
- _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
735
- // Cross-process edit landed on disk; drop this process's short-TTL raw
736
- // config cache synchronously so the debounced reload (and any readAll
737
- // in between) sees the fresh file immediately, not up to TTL stale.
738
- invalidateConfigReadCache();
739
- if (_reloadDebounce) clearTimeout(_reloadDebounce);
740
- _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
741
- });
742
- } catch {}
743
- }
694
+ // Cross-process edits invalidate the raw config cache before the debounced
695
+ // reload so both messaging and automation-only daemon modes stay current.
696
+ ensureConfigWatcher();
744
697
  // Pre-warm the whisper-server manager once at owner startup so the first
745
698
  // voice transcription does not pay cold-start cost. Non-blocking: failures
746
699
  // (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stored automation attachments (schedule/webhook rows) → askSession content.
3
+ * Attachment shape (persisted as jsonb): { kind: 'image'|'text'|'pdf', name,
4
+ * mimeType, data } — image/pdf data is base64, text data is plain text.
5
+ * Text files inline into the prompt body; images/PDFs become the same
6
+ * content parts the desktop composer submits ({type:'image'} /
7
+ * {type:'file'}), so provider media normalization treats them identically.
8
+ */
9
+
10
+ export const AUTOMATION_ATTACHMENT_KINDS = ['image', 'text', 'pdf'];
11
+ export const MAX_AUTOMATION_ATTACHMENTS = 8;
12
+ // Base64 total across image/pdf items; text totals separately.
13
+ export const MAX_AUTOMATION_BINARY_TOTAL = 8_000_000;
14
+ export const MAX_AUTOMATION_TEXT_TOTAL = 200_000;
15
+
16
+ /** Validate + strip a stored/list attachments value to the persisted shape (or null). */
17
+ export function normalizeAutomationAttachments(value) {
18
+ if (!Array.isArray(value) || value.length === 0) return null;
19
+ const out = [];
20
+ let binaryTotal = 0;
21
+ let textTotal = 0;
22
+ for (const entry of value.slice(0, MAX_AUTOMATION_ATTACHMENTS)) {
23
+ if (!entry || typeof entry !== 'object') continue;
24
+ const kind = String(entry.kind || '').trim();
25
+ const data = typeof entry.data === 'string' ? entry.data : '';
26
+ if (!AUTOMATION_ATTACHMENT_KINDS.includes(kind) || !data) continue;
27
+ if (kind === 'text') {
28
+ textTotal += data.length;
29
+ if (textTotal > MAX_AUTOMATION_TEXT_TOTAL) {
30
+ throw new Error('text attachments are too large together (200 KB max)');
31
+ }
32
+ } else {
33
+ binaryTotal += data.length;
34
+ if (binaryTotal > MAX_AUTOMATION_BINARY_TOTAL) {
35
+ throw new Error('image/PDF attachments are too large together (8 MB max)');
36
+ }
37
+ }
38
+ out.push({
39
+ kind,
40
+ name: String(entry.name || '').slice(0, 200) || `attachment-${out.length + 1}`,
41
+ mimeType: String(entry.mimeType || (kind === 'pdf' ? 'application/pdf' : kind === 'image' ? 'image/png' : 'text/plain')),
42
+ data,
43
+ });
44
+ }
45
+ return out.length ? out : null;
46
+ }
47
+
48
+ /**
49
+ * Build the askSession prompt content: plain string when there are no
50
+ * attachments, otherwise a parts array (text + image/file blocks).
51
+ */
52
+ export function automationPromptContent(promptText, attachments) {
53
+ const rows = Array.isArray(attachments) ? attachments : [];
54
+ let text = String(promptText || '');
55
+ const textFiles = rows.filter((entry) => entry?.kind === 'text' && entry.data);
56
+ if (textFiles.length) {
57
+ text += '\n\n' + textFiles
58
+ .map((entry) => `--- Attached file: ${entry.name} ---\n${entry.data}`)
59
+ .join('\n\n');
60
+ }
61
+ const parts = [];
62
+ for (const entry of rows) {
63
+ if (!entry || typeof entry.data !== 'string' || !entry.data) continue;
64
+ if (entry.kind === 'image') {
65
+ parts.push({ type: 'image', data: entry.data, mimeType: entry.mimeType || 'image/png' });
66
+ } else if (entry.kind === 'pdf') {
67
+ parts.push({ type: 'file', data: entry.data, mimeType: entry.mimeType || 'application/pdf', filename: entry.name });
68
+ }
69
+ }
70
+ if (parts.length === 0) return text;
71
+ return [{ type: 'text', text }, ...parts];
72
+ }