mixdog 0.9.66 → 0.9.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +20 -2
- package/src/runtime/channels/lib/config.mjs +13 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
- package/src/runtime/channels/lib/scheduler.mjs +7 -15
- package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
- package/src/runtime/channels/lib/webhook.mjs +46 -246
- package/src/runtime/channels/lib/worker-main.mjs +45 -92
- package/src/runtime/shared/automation-attachments.mjs +72 -0
- package/src/runtime/shared/automation-workflow.mjs +41 -0
- package/src/runtime/shared/config.mjs +0 -9
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/time-format.mjs +56 -0
- package/src/runtime/shared/tool-card-model.mjs +740 -0
- package/src/runtime/shared/webhook-session-run.mjs +57 -0
- package/src/runtime/shared/webhooks-db.mjs +19 -3
- package/src/session-runtime/channel-config-api.mjs +13 -1
- package/src/session-runtime/lifecycle-api.mjs +12 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/runtime-core.mjs +127 -22
- package/src/session-runtime/session-text.mjs +6 -0
- package/src/session-runtime/settings-api.mjs +0 -12
- package/src/standalone/channel-admin.mjs +71 -22
- package/src/standalone/channel-daemon-client.mjs +5 -1
- package/src/standalone/channel-daemon-transport.mjs +112 -20
- package/src/standalone/channel-daemon.mjs +6 -4
- package/src/tui/App.jsx +2 -47
- package/src/tui/app/channel-pickers.mjs +8 -173
- package/src/tui/app/doctor.mjs +0 -1
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/components/ToolExecution.jsx +47 -196
- package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
- package/src/tui/components/tool-execution/text-format.mjs +27 -104
- package/src/tui/dist/index.mjs +340 -346
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/session-api-ext.mjs +32 -16
- package/src/tui/engine.mjs +14 -1
- package/src/tui/time-format.mjs +4 -51
- package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
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,
|
|
@@ -152,6 +153,9 @@ export function _sessionSummary(session) {
|
|
|
152
153
|
preview: messageProjection.preview,
|
|
153
154
|
generation: typeof session.generation === 'number' ? session.generation : 0,
|
|
154
155
|
implicitBashSessionId: session.implicitBashSessionId || null,
|
|
156
|
+
// Lifecycle provenance for catalog filters: resume-machinery scratch
|
|
157
|
+
// forks (detachedReason='cli-resume') must never surface in Recent.
|
|
158
|
+
detachedReason: session.detachedReason || null,
|
|
155
159
|
};
|
|
156
160
|
}
|
|
157
161
|
|
|
@@ -184,6 +188,7 @@ function _normalizeSummaryRow(row) {
|
|
|
184
188
|
preview: _cleanPreview(row.preview || ''),
|
|
185
189
|
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
186
190
|
implicitBashSessionId: row.implicitBashSessionId || null,
|
|
191
|
+
detachedReason: row.detachedReason || null,
|
|
187
192
|
};
|
|
188
193
|
}
|
|
189
194
|
|
|
@@ -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
|
|
|
@@ -49,6 +50,11 @@ function cleanText(value, maximum = 240) {
|
|
|
49
50
|
return String(value || '')
|
|
50
51
|
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
|
|
51
52
|
.replace(/<mcp-instructions>[\s\S]*?<\/mcp-instructions>/gi, ' ')
|
|
53
|
+
// Session-context envelope (mirror of session-text.mjs
|
|
54
|
+
// stripSessionDisplayEnvelope): the "# Session / Cwd / Model /
|
|
55
|
+
// Workflow" header must never become a Recent title.
|
|
56
|
+
.replace(/^\s*# Session\r?\n(?:(?:Cwd|Model|Workflow):[^\r\n]*(?:\r?\n|$))+(?:\r?\n)?/i, ' ')
|
|
57
|
+
.replace(/^\s*#\s*Session\s+Cwd:\s+\S+(?:\s+Model:[^\r\n]*?)?(?:\s+Workflow:\s+\S+)?\s*/i, ' ')
|
|
52
58
|
.replace(/\s+/g, ' ')
|
|
53
59
|
.trim()
|
|
54
60
|
.slice(0, maximum);
|
|
@@ -78,6 +84,10 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
78
84
|
return {
|
|
79
85
|
id: row.id,
|
|
80
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),
|
|
81
91
|
createdAt: positiveNumber(row.createdAt, 0),
|
|
82
92
|
lastHeartbeatAt: positiveNumber(row.lastHeartbeatAt, 0),
|
|
83
93
|
// Liveness comes from the .hb sidecar mtime alone: stored row fields
|
|
@@ -90,6 +100,7 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
90
100
|
agent: row.agent || null,
|
|
91
101
|
sourceType: row.sourceType || null,
|
|
92
102
|
sourceName: row.sourceName || null,
|
|
103
|
+
sourceDelivery: row.sourceDelivery || null,
|
|
93
104
|
scopeKey: row.scopeKey || null,
|
|
94
105
|
ownerSessionId: row.ownerSessionId || null,
|
|
95
106
|
clientHostPid: positiveNumber(row.clientHostPid, 0) || null,
|
|
@@ -105,6 +116,7 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
105
116
|
preview: cleanText(row.preview),
|
|
106
117
|
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
107
118
|
implicitBashSessionId: row.implicitBashSessionId || null,
|
|
119
|
+
detachedReason: row.detachedReason || null,
|
|
108
120
|
};
|
|
109
121
|
}
|
|
110
122
|
|
|
@@ -112,6 +124,10 @@ function rowFromSession(session, heartbeatAt = 0) {
|
|
|
112
124
|
const messages = Array.isArray(session?.messages) ? session.messages : [];
|
|
113
125
|
const preview = messages
|
|
114
126
|
.filter((message) => message?.role === 'user')
|
|
127
|
+
// Cold-path mirror of isSessionPreviewNoise's synthetic skips: compact
|
|
128
|
+
// handoffs and runtime notices must not become session titles.
|
|
129
|
+
.filter((message) => !/^\s*(?:a previous model worked on this task|re-attached after compaction\b|reference files:\s|\[mixdog-runtime\]|the async (?:agent|shell) task\b)/i
|
|
130
|
+
.test(messageText(message.content)))
|
|
115
131
|
.map((message) => cleanText(messageText(message.content)))
|
|
116
132
|
.find(Boolean) || '';
|
|
117
133
|
return normalizedRow({
|
|
@@ -153,7 +169,8 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes()) {
|
|
|
153
169
|
// cold catalog; the authoritative runtime can reconcile it later.
|
|
154
170
|
}
|
|
155
171
|
}
|
|
156
|
-
return rows.sort((left, right) =>
|
|
172
|
+
return rows.sort((left, right) =>
|
|
173
|
+
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
157
174
|
}
|
|
158
175
|
|
|
159
176
|
export function listStoredSessionSummaries(options = {}) {
|
|
@@ -166,7 +183,8 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
166
183
|
const rows = (Array.isArray(index.rows) ? index.rows : [])
|
|
167
184
|
.map((row) => normalizedRow(row, heartbeatMtimes.get(row?.id) || 0))
|
|
168
185
|
.filter((row) => row && isLeadVisibleRow(row))
|
|
169
|
-
.sort((left, right) =>
|
|
186
|
+
.sort((left, right) =>
|
|
187
|
+
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
170
188
|
if (rows.length > 0 || options.rebuildIfMissing === false) return rows;
|
|
171
189
|
}
|
|
172
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.
|
|
@@ -156,6 +157,13 @@ const HEADLESS_BACKEND = {
|
|
|
156
157
|
}
|
|
157
158
|
};
|
|
158
159
|
function createBackend(config) {
|
|
160
|
+
// Channels-module toggle is MESSAGING-ONLY: automation (scheduler/webhooks)
|
|
161
|
+
// keeps the worker alive, so a disabled module runs the headless backend
|
|
162
|
+
// instead of blocking the whole runtime.
|
|
163
|
+
if (config.enabled === false) {
|
|
164
|
+
process.stderr.write("mixdog: channels messaging disabled; channel runtime running in headless mode\n");
|
|
165
|
+
return HEADLESS_BACKEND;
|
|
166
|
+
}
|
|
159
167
|
// Single-backend select: exactly one backend is constructed based on
|
|
160
168
|
// config.backend (discord|telegram). The two are mutually exclusive.
|
|
161
169
|
if (config.backend === "telegram") {
|
|
@@ -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
|
);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Writes <DATA_DIR>/channels/status-snapshot.json every 10 seconds so that
|
|
5
5
|
* setup-server can read cross-process state (cron next-fire, deferred count,
|
|
6
|
-
* Discord unread,
|
|
6
|
+
* Discord unread, relay hook URL) without IPC.
|
|
7
7
|
*
|
|
8
8
|
* Atomic write: tmp → rename so readers never see a partial file.
|
|
9
9
|
*
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as fs from 'fs';
|
|
16
|
-
import * as http from 'http';
|
|
17
16
|
import * as path from 'path';
|
|
18
17
|
import { DATA_DIR } from './config.mjs';
|
|
19
18
|
import { writeJsonAtomicSync } from '../../shared/atomic-file.mjs';
|
|
19
|
+
import { readHookPublicBase } from './webhook/relay-tunnel.mjs';
|
|
20
20
|
|
|
21
21
|
const SNAPSHOT_DIR = path.join(DATA_DIR, 'channels');
|
|
22
22
|
const SNAPSHOT_PATH = path.join(SNAPSHOT_DIR, 'status-snapshot.json');
|
|
@@ -79,30 +79,6 @@ export function recordFetchedMessages(channelId, channelLabel, messages, options
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// ── Ngrok tunnel URL probe ───────────────────────────────────────────────────
|
|
83
|
-
async function probeNgrokUrl() {
|
|
84
|
-
return new Promise((resolve) => {
|
|
85
|
-
const timer = setTimeout(() => { try { req && req.destroy(); } catch {} resolve(null); }, 400);
|
|
86
|
-
let req;
|
|
87
|
-
try {
|
|
88
|
-
req = http.get('http://127.0.0.1:4040/api/tunnels', (res) => {
|
|
89
|
-
clearTimeout(timer);
|
|
90
|
-
let body = '';
|
|
91
|
-
res.on('data', d => { body += d; });
|
|
92
|
-
res.on('end', () => {
|
|
93
|
-
try {
|
|
94
|
-
const parsed = JSON.parse(body);
|
|
95
|
-
const tunnel = (parsed.tunnels || []).find(t => t.public_url);
|
|
96
|
-
resolve(tunnel ? tunnel.public_url : null);
|
|
97
|
-
} catch { resolve(null); }
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
req.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
101
|
-
req.setTimeout(400, () => { clearTimeout(timer); try { req.destroy(); } catch {} resolve(null); });
|
|
102
|
-
} catch { clearTimeout(timer); resolve(null); }
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
82
|
// ── Snapshot computation ─────────────────────────────────────────────────────
|
|
107
83
|
// The legacy HH:MM / everyNm / hourly next-fire fallback was removed: the
|
|
108
84
|
// scheduler accepts cron expressions exclusively (scheduler.mjs:68), so the
|
|
@@ -172,8 +148,8 @@ async function computeSnapshot(scheduler) {
|
|
|
172
148
|
totalUnread += entry.unseenCount;
|
|
173
149
|
}
|
|
174
150
|
|
|
175
|
-
// ──
|
|
176
|
-
const
|
|
151
|
+
// ── Relay hook URL (identity file read; assigned on first tunnel start) ────
|
|
152
|
+
const hookPublicUrl = readHookPublicBase();
|
|
177
153
|
|
|
178
154
|
return {
|
|
179
155
|
writtenAt: now,
|
|
@@ -188,8 +164,8 @@ async function computeSnapshot(scheduler) {
|
|
|
188
164
|
unread: unreadList,
|
|
189
165
|
totalUnread,
|
|
190
166
|
},
|
|
191
|
-
|
|
192
|
-
|
|
167
|
+
hook: {
|
|
168
|
+
publicUrl: hookPublicUrl,
|
|
193
169
|
},
|
|
194
170
|
};
|
|
195
171
|
}
|
|
@@ -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.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Relay-backed public webhook tunnel — the ngrok child process replacement.
|
|
2
|
+
//
|
|
3
|
+
// The channel worker keeps ONE outbound WebSocket to the Mixdog relay
|
|
4
|
+
// (apps/relay/server.mjs `/hookleg`). Inbound requests on
|
|
5
|
+
// https://<relay>/hook/<deviceId>/webhook/<name>
|
|
6
|
+
// arrive over that leg as JSON frames and are replayed against the LOCAL
|
|
7
|
+
// webhook HTTP server; the response returns verbatim. Endpoint HMAC
|
|
8
|
+
// verification stays local — the relay never inspects payloads. Works out
|
|
9
|
+
// of the box: no binary, no authtoken, no reserved domain.
|
|
10
|
+
import * as http from "http";
|
|
11
|
+
import { randomBytes, randomUUID } from "crypto";
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import WebSocket from "ws";
|
|
15
|
+
import { DATA_DIR } from "../config.mjs";
|
|
16
|
+
import { logWebhook } from "./log.mjs";
|
|
17
|
+
|
|
18
|
+
/** Packaged default mirrors the desktop pairing relay. */
|
|
19
|
+
const DEFAULT_RELAY_URL = "wss://192-255-139-161.sslip.io";
|
|
20
|
+
const MAX_TUNNEL_BODY_BYTES = 1024 * 1024;
|
|
21
|
+
const HEARTBEAT_MS = 25_000;
|
|
22
|
+
const LOCAL_TIMEOUT_MS = 25_000;
|
|
23
|
+
|
|
24
|
+
export function resolveHookRelayUrl(env = process.env) {
|
|
25
|
+
const raw = String(env.MIXDOG_RELAY_URL || "").trim();
|
|
26
|
+
const flag = raw.toLowerCase();
|
|
27
|
+
if (flag === "0" || flag === "false" || flag === "off") return null;
|
|
28
|
+
return raw || DEFAULT_RELAY_URL;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hookIdentityPath() {
|
|
32
|
+
return join(DATA_DIR, "relay-hook-device.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Stable per-install identity (trust-on-first-use at the relay, mirroring
|
|
36
|
+
// the desktop leg). The secret never leaves this machine except toward the
|
|
37
|
+
// relay; the deviceId doubles as the public URL path segment.
|
|
38
|
+
export function loadOrCreateHookIdentity() {
|
|
39
|
+
const path = hookIdentityPath();
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
42
|
+
if (typeof parsed.deviceId === "string" && /^[0-9a-f-]{8,64}$/.test(parsed.deviceId)
|
|
43
|
+
&& typeof parsed.deviceSecret === "string" && parsed.deviceSecret.length >= 16) {
|
|
44
|
+
return { deviceId: parsed.deviceId, deviceSecret: parsed.deviceSecret };
|
|
45
|
+
}
|
|
46
|
+
} catch { /* first run */ }
|
|
47
|
+
const identity = { deviceId: randomUUID(), deviceSecret: randomBytes(24).toString("hex") };
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
50
|
+
writeFileSync(path, JSON.stringify(identity, null, 2), "utf8");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
logWebhook(`hook tunnel: identity persist failed — ${err?.message || err}`);
|
|
53
|
+
}
|
|
54
|
+
return identity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hookPublicBase(relayUrl, deviceId) {
|
|
58
|
+
const url = new URL(relayUrl);
|
|
59
|
+
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
60
|
+
url.pathname = `/hook/${deviceId}`;
|
|
61
|
+
url.search = "";
|
|
62
|
+
url.hash = "";
|
|
63
|
+
return url.toString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Public base URL for status surfaces; null until the first tunnel start
|
|
67
|
+
* persisted an identity (no identity is ever created here). */
|
|
68
|
+
export function readHookPublicBase(env = process.env) {
|
|
69
|
+
const relayUrl = resolveHookRelayUrl(env);
|
|
70
|
+
if (!relayUrl) return null;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(readFileSync(hookIdentityPath(), "utf8"));
|
|
73
|
+
if (typeof parsed.deviceId === "string" && parsed.deviceId) {
|
|
74
|
+
return hookPublicBase(relayUrl, parsed.deviceId);
|
|
75
|
+
}
|
|
76
|
+
} catch { /* tunnel has not started yet */ }
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function startHookTunnel({ relayUrl, getLocalPort }) {
|
|
81
|
+
const { deviceId, deviceSecret } = loadOrCreateHookIdentity();
|
|
82
|
+
let socket = null;
|
|
83
|
+
let closed = false;
|
|
84
|
+
let retryMs = 1_000;
|
|
85
|
+
let reconnectTimer = null;
|
|
86
|
+
let announced = false;
|
|
87
|
+
|
|
88
|
+
const scheduleReconnect = () => {
|
|
89
|
+
if (closed) return;
|
|
90
|
+
reconnectTimer = setTimeout(connect, retryMs);
|
|
91
|
+
reconnectTimer.unref?.();
|
|
92
|
+
retryMs = Math.min(30_000, retryMs * 2);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const respond = (ws, id, status, headers, bodyBuffer) => {
|
|
96
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
97
|
+
try {
|
|
98
|
+
ws.send(JSON.stringify({
|
|
99
|
+
type: "http-response",
|
|
100
|
+
id,
|
|
101
|
+
status,
|
|
102
|
+
headers: headers || {},
|
|
103
|
+
body: bodyBuffer && bodyBuffer.length ? bodyBuffer.toString("base64") : "",
|
|
104
|
+
}));
|
|
105
|
+
} catch { /* relay vanished; it times the request out */ }
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const forwardToLocal = (frame, ws) => {
|
|
109
|
+
const port = getLocalPort();
|
|
110
|
+
if (!port) {
|
|
111
|
+
respond(ws, frame.id, 503, { "content-type": "application/json" },
|
|
112
|
+
Buffer.from('{"error":"webhook server not listening"}'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const body = frame.body ? Buffer.from(String(frame.body), "base64") : null;
|
|
116
|
+
const request = http.request({
|
|
117
|
+
host: "127.0.0.1",
|
|
118
|
+
port,
|
|
119
|
+
method: typeof frame.method === "string" ? frame.method : "GET",
|
|
120
|
+
path: typeof frame.path === "string" && frame.path.startsWith("/") ? frame.path : "/",
|
|
121
|
+
headers: frame.headers && typeof frame.headers === "object" ? frame.headers : {},
|
|
122
|
+
timeout: LOCAL_TIMEOUT_MS,
|
|
123
|
+
}, (response) => {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
let total = 0;
|
|
126
|
+
response.on("data", (chunk) => {
|
|
127
|
+
total += chunk.length;
|
|
128
|
+
if (total <= MAX_TUNNEL_BODY_BYTES) chunks.push(chunk);
|
|
129
|
+
});
|
|
130
|
+
response.on("end", () => respond(ws, frame.id, response.statusCode || 502,
|
|
131
|
+
{ "content-type": response.headers["content-type"] || "application/json" },
|
|
132
|
+
Buffer.concat(chunks)));
|
|
133
|
+
response.on("error", () => respond(ws, frame.id, 502, {}, null));
|
|
134
|
+
});
|
|
135
|
+
request.on("timeout", () => request.destroy(new Error("local webhook timeout")));
|
|
136
|
+
request.on("error", (err) => respond(ws, frame.id, 502, { "content-type": "application/json" },
|
|
137
|
+
Buffer.from(JSON.stringify({ error: String(err?.message || err) }))));
|
|
138
|
+
if (body && body.length) request.write(body);
|
|
139
|
+
request.end();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const connect = () => {
|
|
143
|
+
if (closed) return;
|
|
144
|
+
const target = new URL(relayUrl);
|
|
145
|
+
target.pathname = "/hookleg";
|
|
146
|
+
target.search = `device=${encodeURIComponent(deviceId)}&secret=${encodeURIComponent(deviceSecret)}`;
|
|
147
|
+
let ws;
|
|
148
|
+
try {
|
|
149
|
+
ws = new WebSocket(target.toString(), { maxPayload: 8 * 1024 * 1024 });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logWebhook(`hook tunnel: dial failed — ${err?.message || err}`);
|
|
152
|
+
scheduleReconnect();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
socket = ws;
|
|
156
|
+
// NAT paths silently drop idle sockets; protocol pings keep the leg warm
|
|
157
|
+
// and detect a half-dead link so the reconnect loop restores it.
|
|
158
|
+
let alive = true;
|
|
159
|
+
ws.on("pong", () => { alive = true; });
|
|
160
|
+
const heartbeat = setInterval(() => {
|
|
161
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
162
|
+
if (!alive) { try { ws.terminate(); } catch { /* close reconnects */ } return; }
|
|
163
|
+
alive = false;
|
|
164
|
+
try { ws.ping(); } catch { /* close reconnects */ }
|
|
165
|
+
}, HEARTBEAT_MS);
|
|
166
|
+
heartbeat.unref?.();
|
|
167
|
+
ws.on("open", () => {
|
|
168
|
+
retryMs = 1_000;
|
|
169
|
+
if (!announced) {
|
|
170
|
+
announced = true;
|
|
171
|
+
logWebhook(`hook tunnel up: ${hookPublicBase(relayUrl, deviceId)}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
ws.on("message", (raw) => {
|
|
175
|
+
alive = true;
|
|
176
|
+
let frame;
|
|
177
|
+
try { frame = JSON.parse(String(raw)); } catch { return; }
|
|
178
|
+
if (frame?.type !== "http" || typeof frame.id !== "string") return;
|
|
179
|
+
forwardToLocal(frame, ws);
|
|
180
|
+
});
|
|
181
|
+
ws.on("error", () => { /* surfaced as close */ });
|
|
182
|
+
ws.on("close", () => {
|
|
183
|
+
clearInterval(heartbeat);
|
|
184
|
+
if (socket === ws) socket = null;
|
|
185
|
+
scheduleReconnect();
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
connect();
|
|
190
|
+
return {
|
|
191
|
+
deviceId,
|
|
192
|
+
publicBase: hookPublicBase(relayUrl, deviceId),
|
|
193
|
+
close() {
|
|
194
|
+
if (closed) return;
|
|
195
|
+
closed = true;
|
|
196
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
197
|
+
if (socket) {
|
|
198
|
+
try { socket.terminate(); } catch { /* already gone */ }
|
|
199
|
+
socket = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|