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.
Files changed (73) hide show
  1. package/package.json +6 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  12. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  14. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  15. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  16. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  19. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  20. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  21. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  23. package/src/runtime/channels/lib/config.mjs +6 -5
  24. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  25. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  26. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  27. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  28. package/src/runtime/channels/lib/webhook.mjs +36 -46
  29. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  30. package/src/runtime/shared/automation-attachments.mjs +72 -0
  31. package/src/runtime/shared/automation-workflow.mjs +41 -0
  32. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  33. package/src/runtime/shared/schedules-db.mjs +18 -3
  34. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  35. package/src/runtime/shared/webhooks-db.mjs +19 -3
  36. package/src/session-runtime/channel-config-api.mjs +8 -1
  37. package/src/session-runtime/lifecycle-api.mjs +7 -0
  38. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  39. package/src/session-runtime/model-capabilities.mjs +6 -4
  40. package/src/session-runtime/notification-bus.mjs +82 -0
  41. package/src/session-runtime/provider-auth-api.mjs +16 -1
  42. package/src/session-runtime/provider-usage.mjs +3 -0
  43. package/src/session-runtime/remote-control.mjs +166 -0
  44. package/src/session-runtime/remote-transcript.mjs +126 -0
  45. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  46. package/src/session-runtime/runtime-core.mjs +184 -660
  47. package/src/session-runtime/runtime-tunables.mjs +57 -0
  48. package/src/session-runtime/self-update.mjs +129 -0
  49. package/src/session-runtime/skills-api.mjs +77 -0
  50. package/src/session-runtime/tool-surface.mjs +83 -0
  51. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  52. package/src/session-runtime/workflow.mjs +84 -17
  53. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  54. package/src/standalone/agent-tool.mjs +22 -346
  55. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  56. package/src/standalone/channel-admin.mjs +36 -1
  57. package/src/standalone/channel-daemon-client.mjs +5 -1
  58. package/src/standalone/channel-daemon-transport.mjs +112 -20
  59. package/src/standalone/channel-daemon.mjs +6 -4
  60. package/src/standalone/channel-worker.mjs +7 -13
  61. package/src/tui/App.jsx +2 -32
  62. package/src/tui/app/channel-pickers.mjs +2 -143
  63. package/src/tui/app/slash-commands.mjs +1 -3
  64. package/src/tui/app/slash-dispatch.mjs +3 -3
  65. package/src/tui/components/StatusLine.jsx +6 -5
  66. package/src/tui/dist/index.mjs +162 -259
  67. package/src/tui/engine/labels.mjs +0 -8
  68. package/src/tui/engine/session-api-ext.mjs +45 -10
  69. package/src/tui/engine/turn-watchdog.mjs +92 -0
  70. package/src/tui/engine/turn.mjs +18 -70
  71. package/src/tui/engine.mjs +14 -1
  72. package/src/workflows/default/WORKFLOW.md +1 -1
  73. 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
- 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.
@@ -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 no webhook secret configured, skipping signature verification`);
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 _dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channel) {
412
+ async _dispatchSessionRun(name, model, fullPrompt, headers, deliveryId, res, extra = {}) {
401
413
  await updateDeliveryStatus(name, deliveryId, "processing");
402
- // Bridge dispatch must not be allowed to hang forever — without a
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 delegate task we ship.
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
- role,
410
- preset: model,
421
+ model: model || null,
411
422
  prompt: fullPrompt,
412
- cwd: this.config?.cwd,
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}: delegate dispatched to bridge (role=${role}, id=${deliveryId})`);
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}: delegate dispatch failed: ${err?.message || err}`);
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: "delegate", id: deliveryId }));
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 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.
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 { channelId, role, model, instructions } = endpoint;
462
+ const { model, instructions, cwd, workflow, attachments, delivery } = endpoint;
448
463
  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 }));
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
- 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
+ }
@@ -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
+ }