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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run an inbound webhook as a VISIBLE Mixdog session (schedules parity, user
|
|
3
|
+
* decision): no Lead-context injection, no channel forward — every fire is a
|
|
4
|
+
* fresh New task with the endpoint's prompt/model/workflow/project, owned by
|
|
5
|
+
* 'user' + sourceType 'webhook' (sidebar Automations section, newest per
|
|
6
|
+
* name). Invoked from the channels worker's webhook dispatch.
|
|
7
|
+
*/
|
|
8
|
+
import { loadConfig } from '../agent/orchestrator/config.mjs';
|
|
9
|
+
import { createSession } from '../agent/orchestrator/session/manager/session-lifecycle.mjs';
|
|
10
|
+
import { askSession } from '../agent/orchestrator/session/manager/ask-session.mjs';
|
|
11
|
+
import { parseScheduleModelRef } from './schedule-model-ref.mjs';
|
|
12
|
+
import { automationWorkflowOpts } from './automation-workflow.mjs';
|
|
13
|
+
import { automationPromptContent } from './automation-attachments.mjs';
|
|
14
|
+
|
|
15
|
+
/** Endpoint model ref wins; the maintenance.webhook route is the fallback. */
|
|
16
|
+
function webhookRoute(modelRef) {
|
|
17
|
+
if (modelRef) {
|
|
18
|
+
const ref = parseScheduleModelRef(modelRef);
|
|
19
|
+
if (ref && typeof ref === 'object') return ref;
|
|
20
|
+
}
|
|
21
|
+
const cfg = loadConfig({ secrets: false });
|
|
22
|
+
const maintenance = cfg?.maintenance?.webhook;
|
|
23
|
+
if (maintenance?.provider && maintenance?.model) {
|
|
24
|
+
return { provider: maintenance.provider, model: maintenance.model };
|
|
25
|
+
}
|
|
26
|
+
throw new Error('webhook run has no model: set one on the endpoint or configure maintenance.webhook');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runWebhookSession({ name, model = null, prompt, cwd = null, workflow = null, attachments = null, delivery = null }) {
|
|
30
|
+
const endpoint = String(name || '').trim();
|
|
31
|
+
const body = String(prompt || '').trim();
|
|
32
|
+
if (!endpoint) throw new Error('runWebhookSession: endpoint name required');
|
|
33
|
+
if (!body) throw new Error(`webhook "${endpoint}" has no prompt body`);
|
|
34
|
+
const route = webhookRoute(model);
|
|
35
|
+
const projectCwd = cwd ? String(cwd) : null;
|
|
36
|
+
const session = createSession({
|
|
37
|
+
provider: route.provider,
|
|
38
|
+
model: route.model,
|
|
39
|
+
...(route.effort ? { effort: route.effort } : {}),
|
|
40
|
+
...(route.fast === true ? { fast: true } : {}),
|
|
41
|
+
owner: 'user',
|
|
42
|
+
sourceType: 'webhook',
|
|
43
|
+
sourceName: endpoint,
|
|
44
|
+
sourceDelivery: delivery || null,
|
|
45
|
+
...(projectCwd ? { cwd: projectCwd } : {}),
|
|
46
|
+
desktopSession: projectCwd
|
|
47
|
+
? { classification: 'project', projectPath: projectCwd }
|
|
48
|
+
: { classification: 'task', projectPath: null },
|
|
49
|
+
...automationWorkflowOpts(workflow),
|
|
50
|
+
});
|
|
51
|
+
// The user message leads with the endpoint's instructions, so Recent titles
|
|
52
|
+
// read as the user-authored intent rather than payload noise.
|
|
53
|
+
// Stored attachments ride along as composer-style content parts.
|
|
54
|
+
const content = automationPromptContent(body, attachments);
|
|
55
|
+
const result = await askSession(session.id, content, null, null, projectCwd || undefined);
|
|
56
|
+
return { sessionId: session.id, result: String(result?.content || '') };
|
|
57
|
+
}
|
|
@@ -40,6 +40,10 @@ CREATE TABLE IF NOT EXISTS webhooks.endpoints (
|
|
|
40
40
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
41
41
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
42
42
|
);
|
|
43
|
+
ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS cwd text;
|
|
44
|
+
ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS workflow text;
|
|
45
|
+
ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS attachments jsonb;
|
|
46
|
+
ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS delivery text;
|
|
43
47
|
CREATE TABLE IF NOT EXISTS webhooks.deliveries (
|
|
44
48
|
endpoint text NOT NULL,
|
|
45
49
|
delivery_id text NOT NULL,
|
|
@@ -77,7 +81,7 @@ async function getDb(dataDir = resolvePluginData()) {
|
|
|
77
81
|
// Row <-> def mapping
|
|
78
82
|
// ---------------------------------------------------------------------------
|
|
79
83
|
|
|
80
|
-
const ENDPOINT_COLS = 'name, description, channel_id, role, model, parser, secret, instructions, enabled, created_at, updated_at';
|
|
84
|
+
const ENDPOINT_COLS = 'name, description, channel_id, role, model, parser, secret, cwd, workflow, attachments, delivery, instructions, enabled, created_at, updated_at';
|
|
81
85
|
|
|
82
86
|
function rowToEndpoint(row) {
|
|
83
87
|
if (!row) return null;
|
|
@@ -88,6 +92,10 @@ function rowToEndpoint(row) {
|
|
|
88
92
|
role: row.role,
|
|
89
93
|
model: row.model,
|
|
90
94
|
parser: row.parser,
|
|
95
|
+
cwd: row.cwd,
|
|
96
|
+
workflow: row.workflow,
|
|
97
|
+
attachments: row.attachments || null,
|
|
98
|
+
delivery: row.delivery || null,
|
|
91
99
|
// Never project the plaintext secret through list/load config paths;
|
|
92
100
|
// callers get a presence flag and must fetch the value via
|
|
93
101
|
// readEndpointSecret (the single, explicit secret-read path).
|
|
@@ -165,13 +173,17 @@ export async function upsertEndpoint(def, { dataDir } = {}) {
|
|
|
165
173
|
def.model ?? null,
|
|
166
174
|
def.parser ?? null,
|
|
167
175
|
def.secret ?? null,
|
|
176
|
+
def.cwd ?? null,
|
|
177
|
+
def.workflow ?? null,
|
|
178
|
+
def.attachments ? JSON.stringify(def.attachments) : null,
|
|
179
|
+
def.delivery ?? null,
|
|
168
180
|
def.instructions ?? '',
|
|
169
181
|
def.enabled ?? true,
|
|
170
182
|
];
|
|
171
183
|
const { rows } = await db.query(
|
|
172
184
|
`INSERT INTO webhooks.endpoints
|
|
173
|
-
(name, description, channel_id, role, model, parser, secret, instructions, enabled)
|
|
174
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
185
|
+
(name, description, channel_id, role, model, parser, secret, cwd, workflow, attachments, delivery, instructions, enabled)
|
|
186
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
175
187
|
ON CONFLICT (name) DO UPDATE SET
|
|
176
188
|
description = EXCLUDED.description,
|
|
177
189
|
channel_id = EXCLUDED.channel_id,
|
|
@@ -179,6 +191,10 @@ export async function upsertEndpoint(def, { dataDir } = {}) {
|
|
|
179
191
|
model = EXCLUDED.model,
|
|
180
192
|
parser = EXCLUDED.parser,
|
|
181
193
|
secret = EXCLUDED.secret,
|
|
194
|
+
cwd = EXCLUDED.cwd,
|
|
195
|
+
workflow = EXCLUDED.workflow,
|
|
196
|
+
attachments = EXCLUDED.attachments,
|
|
197
|
+
delivery = EXCLUDED.delivery,
|
|
182
198
|
instructions = EXCLUDED.instructions,
|
|
183
199
|
enabled = EXCLUDED.enabled,
|
|
184
200
|
updated_at = now()
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
channelSetup,
|
|
3
3
|
deleteSchedule,
|
|
4
4
|
deleteWebhook,
|
|
5
|
+
getWebhookSecret,
|
|
5
6
|
setChannelAsync,
|
|
6
7
|
saveSchedule,
|
|
7
8
|
saveWebhook,
|
|
@@ -16,7 +17,7 @@ import { runScheduleSession } from '../runtime/shared/schedule-session-run.mjs';
|
|
|
16
17
|
// API object; the mutating admin helpers are imported directly here and the
|
|
17
18
|
// runtime injects only the closure-owned callbacks (backend flush, channel
|
|
18
19
|
// worker handle, soft reload).
|
|
19
|
-
export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon }) {
|
|
20
|
+
export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon, ensureAutomationRuntime = () => {} }) {
|
|
20
21
|
return {
|
|
21
22
|
async getChannelSetup() {
|
|
22
23
|
// Flush a pending debounced backend switch first so setup readers
|
|
@@ -41,6 +42,10 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
|
|
|
41
42
|
async saveSchedule(entry) {
|
|
42
43
|
const result = await saveSchedule(entry);
|
|
43
44
|
reloadChannelsSoon();
|
|
45
|
+
// First automation created while the app is already running: the boot-
|
|
46
|
+
// time autostart check has passed, so kick the worker start path now
|
|
47
|
+
// (no-op when it is already up).
|
|
48
|
+
ensureAutomationRuntime();
|
|
44
49
|
return result;
|
|
45
50
|
},
|
|
46
51
|
async deleteSchedule(name) {
|
|
@@ -51,11 +56,13 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
|
|
|
51
56
|
async setScheduleEnabled(name, enabled) {
|
|
52
57
|
const result = await setScheduleEnabled(name, enabled);
|
|
53
58
|
reloadChannelsSoon();
|
|
59
|
+
if (enabled !== false) ensureAutomationRuntime();
|
|
54
60
|
return result;
|
|
55
61
|
},
|
|
56
62
|
async saveWebhook(entry) {
|
|
57
63
|
const result = await saveWebhook(entry);
|
|
58
64
|
reloadChannelsSoon();
|
|
65
|
+
ensureAutomationRuntime();
|
|
59
66
|
return result;
|
|
60
67
|
},
|
|
61
68
|
async deleteWebhook(name) {
|
|
@@ -66,8 +73,13 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
|
|
|
66
73
|
async setWebhookEnabled(name, enabled) {
|
|
67
74
|
const result = await setWebhookEnabled(name, enabled);
|
|
68
75
|
reloadChannelsSoon();
|
|
76
|
+
if (enabled !== false) ensureAutomationRuntime();
|
|
69
77
|
return result;
|
|
70
78
|
},
|
|
79
|
+
// Read-only secret fetch for the webhook editor; no worker reload.
|
|
80
|
+
async getWebhookSecret(name) {
|
|
81
|
+
return getWebhookSecret(name);
|
|
82
|
+
},
|
|
71
83
|
async runScheduleNow(name) {
|
|
72
84
|
const id = String(name || '').trim();
|
|
73
85
|
const schedule = await getSchedule(id);
|
|
@@ -69,6 +69,9 @@ export function createLifecycleApi(deps) {
|
|
|
69
69
|
// Recent / TUI resume next to lead sessions instead of hiding like
|
|
70
70
|
// agent dispatches.
|
|
71
71
|
|| sourceType === 'schedule'
|
|
72
|
+
// Webhook fires run as visible sessions too (user decision: no Lead
|
|
73
|
+
// injection — the session row IS the notification).
|
|
74
|
+
|| sourceType === 'webhook'
|
|
72
75
|
|| (!sourceType && !sourceName && !isAgentOwner(owner));
|
|
73
76
|
if (!leadish) return null;
|
|
74
77
|
const rawPreview = s.preview || '';
|
|
@@ -89,6 +92,11 @@ export function createLifecycleApi(deps) {
|
|
|
89
92
|
return {
|
|
90
93
|
id: s.id,
|
|
91
94
|
updatedAt: s.updatedAt,
|
|
95
|
+
// Conversation-activity timestamp for Recent ordering. Without this the
|
|
96
|
+
// desktop falls back to updatedAt, which detach/resume bookkeeping
|
|
97
|
+
// bumps — clicking a session reshuffled the sidebar (the row just left
|
|
98
|
+
// jumped to the top).
|
|
99
|
+
lastUsedAt: Number(s.lastUsedAt) || 0,
|
|
92
100
|
cwd: s.cwd || '',
|
|
93
101
|
model: s.model,
|
|
94
102
|
provider: s.provider,
|
|
@@ -101,6 +109,10 @@ export function createLifecycleApi(deps) {
|
|
|
101
109
|
// after a turn had already finished.
|
|
102
110
|
heartbeatAt: Number(heartbeatMtimes.get(s.id)) || 0,
|
|
103
111
|
desktopSession: s.desktopSession || null,
|
|
112
|
+
// Automation origin: lets the desktop group schedule/webhook runner
|
|
113
|
+
// sessions under the sidebar Automations section instead of Recent.
|
|
114
|
+
sourceType: sourceType || null,
|
|
115
|
+
sourceName: clean(s.sourceName || '') || null,
|
|
104
116
|
};
|
|
105
117
|
}).filter(Boolean);
|
|
106
118
|
};
|
|
@@ -15,6 +15,7 @@ export function createPrewarmSchedulers({
|
|
|
15
15
|
getSession,
|
|
16
16
|
isRemoteEnabled,
|
|
17
17
|
channelsEnabled,
|
|
18
|
+
hasActiveAutomation,
|
|
18
19
|
getCodeGraphModule,
|
|
19
20
|
createCurrentSession,
|
|
20
21
|
channels,
|
|
@@ -111,25 +112,30 @@ export function createPrewarmSchedulers({
|
|
|
111
112
|
bootProfile('channels:start-skipped');
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
|
-
if (!channelsEnabled()) {
|
|
115
|
-
bootProfile('channels:start-disabled');
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
115
|
if (timers.channelStartTimer || state.channelStartPromise || isCloseRequested()) return;
|
|
119
116
|
bootProfile('channels:start-scheduled', { delayMs });
|
|
120
|
-
timers.channelStartTimer = setTimeout(() => {
|
|
117
|
+
timers.channelStartTimer = setTimeout(() => void (async () => {
|
|
121
118
|
timers.channelStartTimer = null;
|
|
122
119
|
if (isCloseRequested()) return;
|
|
120
|
+
// Channels-module and remote toggles gate MESSAGING; automation
|
|
121
|
+
// (enabled schedules/webhooks) keeps the worker boot alive — its
|
|
122
|
+
// backend runs headless when messaging is off or unconfigured.
|
|
123
|
+
const automation = await hasActiveAutomation().catch(() => false);
|
|
124
|
+
if (!channelsEnabled() && !automation) {
|
|
125
|
+
bootProfile('channels:start-disabled');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
123
128
|
// A deferred start may straddle a stopRemote(); re-check before booting so
|
|
124
129
|
// a turned-off session neither starts channels nor keeps rescheduling.
|
|
125
|
-
if (!isRemoteEnabled()) return;
|
|
130
|
+
if (!isRemoteEnabled() && !automation) return;
|
|
131
|
+
if (isCloseRequested()) return;
|
|
126
132
|
if (getActiveTurnCount() > 0 || getSessionCreatePromise()) {
|
|
127
133
|
bootProfile('channels:start-deferred', { reason: getActiveTurnCount() > 0 ? 'turn-active' : 'session-create' });
|
|
128
134
|
scheduleChannelStart(backgroundBusyRetryMs);
|
|
129
135
|
return;
|
|
130
136
|
}
|
|
131
137
|
void invokeChannelStart();
|
|
132
|
-
}, delayMs);
|
|
138
|
+
})(), delayMs);
|
|
133
139
|
timers.channelStartTimer.unref?.();
|
|
134
140
|
}
|
|
135
141
|
|
|
@@ -65,13 +65,12 @@ import {
|
|
|
65
65
|
deleteWebhook,
|
|
66
66
|
forgetDiscordToken,
|
|
67
67
|
forgetTelegramToken,
|
|
68
|
-
|
|
68
|
+
hasActiveAutomation,
|
|
69
69
|
setChannel,
|
|
70
70
|
saveDiscordToken,
|
|
71
71
|
saveTelegramToken,
|
|
72
72
|
saveSchedule,
|
|
73
73
|
saveWebhook,
|
|
74
|
-
saveWebhookAuthtoken,
|
|
75
74
|
setBackend,
|
|
76
75
|
setBackendAsync,
|
|
77
76
|
setScheduleEnabled,
|
|
@@ -330,6 +329,11 @@ export async function createMixdogSessionRuntime({
|
|
|
330
329
|
// past its remoteEnabled guards without prematurely showing this session as
|
|
331
330
|
// remote (single-holder: a live owner must not be stolen by autoStart).
|
|
332
331
|
let remoteClaimPending = false;
|
|
332
|
+
// SESSION-SCOPED remote (user decision): the session that toggled remote
|
|
333
|
+
// OWNS the channel relay. Other sessions' turns never rebind or relay; a
|
|
334
|
+
// dead/replaced owner (clear, delete) hands the seat to the next current
|
|
335
|
+
// session that asks.
|
|
336
|
+
let remoteSessionId = null;
|
|
333
337
|
// Remote-mode transcript writer (Discord outbound). Lazily created per
|
|
334
338
|
// session.id + cwd inside ask(); only active while remoteEnabled.
|
|
335
339
|
let _transcriptWriter = null;
|
|
@@ -1033,6 +1037,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1033
1037
|
// would re-fork/activate). Idempotent: no-op when already enabled.
|
|
1034
1038
|
if (msg?.params?.state === 'acquired' && !remoteEnabled) {
|
|
1035
1039
|
remoteEnabled = true;
|
|
1040
|
+
if (!remoteSessionId) remoteSessionId = session?.id || null;
|
|
1036
1041
|
ensureRemoteTranscriptWriter();
|
|
1037
1042
|
// Auto-acquire: the worker restored yesterday's transcript from
|
|
1038
1043
|
// persisted status and we just created the CURRENT writer. Push the
|
|
@@ -1694,6 +1699,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1694
1699
|
getSession: () => session,
|
|
1695
1700
|
isRemoteEnabled: () => remoteEnabled,
|
|
1696
1701
|
channelsEnabled,
|
|
1702
|
+
hasActiveAutomation,
|
|
1697
1703
|
getCodeGraphModule,
|
|
1698
1704
|
createCurrentSession,
|
|
1699
1705
|
channels,
|
|
@@ -1719,6 +1725,16 @@ export async function createMixdogSessionRuntime({
|
|
|
1719
1725
|
// when a writer is bound.
|
|
1720
1726
|
function ensureRemoteTranscriptWriter() {
|
|
1721
1727
|
if (!remoteEnabled || !session?.id) return false;
|
|
1728
|
+
// Owner gate: late adopt covers lazy create (owner unknown at /remote
|
|
1729
|
+
// time); a live owner blocks every other session; a dead owner (cleared
|
|
1730
|
+
// or deleted session) hands the relay to the current session.
|
|
1731
|
+
if (!remoteSessionId) {
|
|
1732
|
+
remoteSessionId = session.id;
|
|
1733
|
+
} else if (session.id !== remoteSessionId) {
|
|
1734
|
+
const owner = mgr.getSession(remoteSessionId);
|
|
1735
|
+
if (owner && owner.closed !== true && owner.status !== 'closed') return false;
|
|
1736
|
+
remoteSessionId = session.id;
|
|
1737
|
+
}
|
|
1722
1738
|
const twKey = `${session.id}\u0000${currentCwd}`;
|
|
1723
1739
|
if (_twKey !== twKey) {
|
|
1724
1740
|
try {
|
|
@@ -1826,6 +1842,9 @@ export async function createMixdogSessionRuntime({
|
|
|
1826
1842
|
remoteClaimPending = true;
|
|
1827
1843
|
} else {
|
|
1828
1844
|
remoteEnabled = true;
|
|
1845
|
+
// Explicit toggle: the CURRENT session owns the relay (lazy create
|
|
1846
|
+
// adopts inside ensureRemoteTranscriptWriter when no session exists yet).
|
|
1847
|
+
remoteSessionId = session?.id || null;
|
|
1829
1848
|
}
|
|
1830
1849
|
// Boot the memory daemon eagerly. The channels worker forwards
|
|
1831
1850
|
// transcript ingests/entries to the memory HTTP service, whose port is
|
|
@@ -1884,10 +1903,6 @@ export async function createMixdogSessionRuntime({
|
|
|
1884
1903
|
bootProfile('channels:start-skipped');
|
|
1885
1904
|
return true;
|
|
1886
1905
|
}
|
|
1887
|
-
if (!channelsEnabled()) {
|
|
1888
|
-
bootProfile('channels:start-disabled');
|
|
1889
|
-
return true;
|
|
1890
|
-
}
|
|
1891
1906
|
if (closeRequested) return true;
|
|
1892
1907
|
if (prewarmTimers.channelStartTimer) {
|
|
1893
1908
|
clearTimeout(prewarmTimers.channelStartTimer);
|
|
@@ -1895,6 +1910,14 @@ export async function createMixdogSessionRuntime({
|
|
|
1895
1910
|
}
|
|
1896
1911
|
bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
|
|
1897
1912
|
void (async () => {
|
|
1913
|
+
// Channels-module toggle gates MESSAGING only: automation (schedules/
|
|
1914
|
+
// webhooks) still boots the worker — its backend runs headless when
|
|
1915
|
+
// messaging is off or unconfigured (see channels/lib/config.mjs).
|
|
1916
|
+
if (!channelsEnabled() && !(await hasActiveAutomation().catch(() => false))) {
|
|
1917
|
+
bootProfile('channels:start-disabled');
|
|
1918
|
+
remoteClaimPending = false;
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1898
1921
|
// A backend switch may still be pending or writing asynchronously. Drain
|
|
1899
1922
|
// it before the worker reads config; never race it with a sync lock wait.
|
|
1900
1923
|
try { await flushBackendSave(); } catch {}
|
|
@@ -1933,23 +1956,63 @@ export async function createMixdogSessionRuntime({
|
|
|
1933
1956
|
else process.env.MIXDOG_REMOTE_INTENT = _prevIntent;
|
|
1934
1957
|
}
|
|
1935
1958
|
if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
|
|
1936
|
-
// Explicit
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1959
|
+
// Explicit claims are unconditional. Auto-start dispatches the same
|
|
1960
|
+
// activation as claim-if-vacant so it can promote an existing
|
|
1961
|
+
// automation-only daemon without stealing a live remote owner.
|
|
1962
|
+
await channels.execute('activate_channel_bridge', {
|
|
1963
|
+
active: true,
|
|
1964
|
+
claimIfVacant: intent === 'auto',
|
|
1965
|
+
sessionId: session?.id || remoteSessionId || null,
|
|
1966
|
+
transcriptPath: _transcriptWriter?.transcriptPath || null,
|
|
1967
|
+
});
|
|
1944
1968
|
// Claim attempt dispatched; the worker's acquire/supersede notification
|
|
1945
1969
|
// now owns the remoteEnabled transition. Drop the transient marker.
|
|
1946
1970
|
remoteClaimPending = false;
|
|
1947
|
-
})().catch((error) => {
|
|
1971
|
+
})().catch((error) => {
|
|
1972
|
+
remoteClaimPending = false;
|
|
1973
|
+
if (remoteEnabled) {
|
|
1974
|
+
remoteEnabled = false;
|
|
1975
|
+
remoteSessionId = null;
|
|
1976
|
+
channels.stop('remote-claim-failed').catch(() => {});
|
|
1977
|
+
emitRemoteStateChange(false, 'claim-failed');
|
|
1978
|
+
}
|
|
1979
|
+
bootProfile('channels:claim-failed', { error: error?.message || String(error) });
|
|
1980
|
+
});
|
|
1948
1981
|
return true;
|
|
1949
1982
|
}
|
|
1950
1983
|
|
|
1984
|
+
// Explicit user OFF is a desired-state command, not a toggle retry. Ask the
|
|
1985
|
+
// daemon to deactivate the machine-global bridge before detaching this
|
|
1986
|
+
// client; the call runs in the background so the UI never waits through a
|
|
1987
|
+
// daemon spawn/reconnect window. Repeated releases remain idempotent.
|
|
1988
|
+
function releaseRemote(reason) {
|
|
1989
|
+
const releasingSessionId = remoteSessionId || session?.id || null;
|
|
1990
|
+
remoteEnabled = false;
|
|
1991
|
+
remoteSessionId = null;
|
|
1992
|
+
remoteClaimPending = false;
|
|
1993
|
+
if (prewarmTimers.channelStartTimer) {
|
|
1994
|
+
clearTimeout(prewarmTimers.channelStartTimer);
|
|
1995
|
+
prewarmTimers.channelStartTimer = null;
|
|
1996
|
+
}
|
|
1997
|
+
void (async () => {
|
|
1998
|
+
try {
|
|
1999
|
+
await channels.execute('activate_channel_bridge', {
|
|
2000
|
+
active: false,
|
|
2001
|
+
sessionId: releasingSessionId,
|
|
2002
|
+
});
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
bootProfile('channels:release-failed', { error: error?.message || String(error) });
|
|
2005
|
+
emitRemoteStateChange(false, 'release-failed');
|
|
2006
|
+
} finally {
|
|
2007
|
+
await channels.stop(reason || 'remote-disabled').catch(() => {});
|
|
2008
|
+
}
|
|
2009
|
+
})();
|
|
2010
|
+
return false;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
1951
2013
|
function stopRemote(reason) {
|
|
1952
2014
|
remoteEnabled = false;
|
|
2015
|
+
remoteSessionId = null;
|
|
1953
2016
|
// A pending auto-claim is abandoned by an explicit stop/supersede.
|
|
1954
2017
|
remoteClaimPending = false;
|
|
1955
2018
|
// Cancel any pending deferred start so it can't fire after remote is off.
|
|
@@ -2004,8 +2067,11 @@ export async function createMixdogSessionRuntime({
|
|
|
2004
2067
|
// Channels are opt-in: only boot the worker when this session started in (or
|
|
2005
2068
|
// was toggled into) remote mode. Non-remote sessions never contend for the
|
|
2006
2069
|
// channel; see startRemote()/stopRemote() and the `/remote` toggle.
|
|
2007
|
-
// `remote.autoStart` in mixdog-config.json makes
|
|
2008
|
-
// at boot
|
|
2070
|
+
// `remote.autoStart` in mixdog-config.json makes terminal sessions attempt a
|
|
2071
|
+
// remote claim at boot. Desktop sessions are explicit-only: opening the app
|
|
2072
|
+
// must never claim or spawn the channel bridge until its Remote button is
|
|
2073
|
+
// pressed, while the shared owner-state watcher still reflects a terminal
|
|
2074
|
+
// that already owns it.
|
|
2009
2075
|
// The flag lives in the TOP-LEVEL `remote` section of mixdog-config.json
|
|
2010
2076
|
// (sibling of agent/ui/channels), not inside the agent section that
|
|
2011
2077
|
// cfgMod.loadConfig returns — read it via the shared whole-file reader.
|
|
@@ -2018,7 +2084,7 @@ export async function createMixdogSessionRuntime({
|
|
|
2018
2084
|
// known to have won). Track it as a separate deferred auto request; the
|
|
2019
2085
|
// worker's acquire notification flips remoteEnabled if/when it wins the seat.
|
|
2020
2086
|
let remoteAutoStartRequested = false;
|
|
2021
|
-
if (!remoteEnabled) {
|
|
2087
|
+
if (!remoteEnabled && !desktopSession) {
|
|
2022
2088
|
try {
|
|
2023
2089
|
if (config?.remote?.autoStart === true
|
|
2024
2090
|
|| sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
|
|
@@ -2035,9 +2101,9 @@ export async function createMixdogSessionRuntime({
|
|
|
2035
2101
|
// early /remote (startRemote clears it), stopRemote(), and close() all
|
|
2036
2102
|
// cancel it through the existing clearTimeout paths. Runtime /remote calls
|
|
2037
2103
|
// still start immediately (user-initiated, UI already painted).
|
|
2104
|
+
const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
|
|
2038
2105
|
if (remoteEnabled || remoteAutoStartRequested) {
|
|
2039
2106
|
const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
|
|
2040
|
-
const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
|
|
2041
2107
|
bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
|
|
2042
2108
|
prewarmTimers.channelStartTimer = setTimeout(() => {
|
|
2043
2109
|
prewarmTimers.channelStartTimer = null;
|
|
@@ -2048,6 +2114,23 @@ export async function createMixdogSessionRuntime({
|
|
|
2048
2114
|
startRemote({ intent: remoteStartIntent });
|
|
2049
2115
|
}, remoteAutoStartDelayMs);
|
|
2050
2116
|
prewarmTimers.channelStartTimer.unref?.();
|
|
2117
|
+
} else {
|
|
2118
|
+
// Automation decoupling (user decision): enabled schedules/webhooks boot
|
|
2119
|
+
// the worker on their own — no remote flag, no remote.autoStart, and no
|
|
2120
|
+
// messaging backend. A later explicit/auto Remote claim promotes the same
|
|
2121
|
+
// daemon without restarting schedules or webhooks.
|
|
2122
|
+
prewarmTimers.channelStartTimer = setTimeout(() => {
|
|
2123
|
+
prewarmTimers.channelStartTimer = null;
|
|
2124
|
+
if (closeRequested || remoteEnabled) return;
|
|
2125
|
+
void hasActiveAutomation()
|
|
2126
|
+
.then((active) => {
|
|
2127
|
+
if (!active || closeRequested || remoteEnabled) return;
|
|
2128
|
+
bootProfile('channels:automation-autostart');
|
|
2129
|
+
void invokeChannelStart();
|
|
2130
|
+
})
|
|
2131
|
+
.catch(() => { /* automation probe is best-effort */ });
|
|
2132
|
+
}, remoteAutoStartDelayMs);
|
|
2133
|
+
prewarmTimers.channelStartTimer.unref?.();
|
|
2051
2134
|
}
|
|
2052
2135
|
|
|
2053
2136
|
// Pure settings-delegate methods (onboarding status/skip, autoClear, profile,
|
|
@@ -2103,12 +2186,17 @@ export async function createMixdogSessionRuntime({
|
|
|
2103
2186
|
forgetDiscordToken,
|
|
2104
2187
|
saveTelegramToken,
|
|
2105
2188
|
forgetTelegramToken,
|
|
2106
|
-
saveWebhookAuthtoken,
|
|
2107
|
-
forgetWebhookAuthtoken,
|
|
2108
2189
|
setBackend,
|
|
2109
2190
|
});
|
|
2110
2191
|
|
|
2111
|
-
const channelConfigApi = createChannelConfigApi({
|
|
2192
|
+
const channelConfigApi = createChannelConfigApi({
|
|
2193
|
+
flushBackendSave,
|
|
2194
|
+
channels,
|
|
2195
|
+
reloadChannelsSoon,
|
|
2196
|
+
// Automation saved mid-session boots the worker (claim-if-vacant) even
|
|
2197
|
+
// though the boot-time autostart window has already passed.
|
|
2198
|
+
ensureAutomationRuntime: () => scheduleChannelStart(0),
|
|
2199
|
+
});
|
|
2112
2200
|
const providerAuthApi = createProviderAuthApi({
|
|
2113
2201
|
cfgMod,
|
|
2114
2202
|
getConfig: () => config,
|
|
@@ -2385,9 +2473,26 @@ export async function createMixdogSessionRuntime({
|
|
|
2385
2473
|
stopRemote(reason) {
|
|
2386
2474
|
return stopRemote(reason);
|
|
2387
2475
|
},
|
|
2476
|
+
releaseRemote(reason) {
|
|
2477
|
+
return releaseRemote(reason);
|
|
2478
|
+
},
|
|
2388
2479
|
isRemoteEnabled() {
|
|
2389
2480
|
return isRemoteEnabled();
|
|
2390
2481
|
},
|
|
2482
|
+
// Session-scoped remote: the owning session id (null when off/unassigned).
|
|
2483
|
+
getRemoteSessionId() {
|
|
2484
|
+
return remoteSessionId;
|
|
2485
|
+
},
|
|
2486
|
+
// Move the relay seat to the CURRENT session (last-wins within this
|
|
2487
|
+
// engine): rebind the transcript writer + forwarder without restarting
|
|
2488
|
+
// the worker. No-op when remote is off or no session exists.
|
|
2489
|
+
claimRemoteForCurrentSession() {
|
|
2490
|
+
if (!remoteEnabled || !session?.id) return false;
|
|
2491
|
+
remoteSessionId = session.id;
|
|
2492
|
+
ensureRemoteTranscriptWriter();
|
|
2493
|
+
pushTranscriptRebind();
|
|
2494
|
+
return true;
|
|
2495
|
+
},
|
|
2391
2496
|
// Subscribe to non-user-initiated remote flips (seat superseded). Returns
|
|
2392
2497
|
// an unsubscribe function. TUI uses this to sync its Remote indicator and
|
|
2393
2498
|
// show a "remote taken over" notice.
|
|
@@ -39,6 +39,12 @@ const SYNTHETIC_SESSION_TEXT_PATTERNS = Object.freeze([
|
|
|
39
39
|
/^\[mixdog-runtime\]/i,
|
|
40
40
|
/^\[(?:truncated|request interrupted by user)\]$/i,
|
|
41
41
|
/^a previous model worked on this task and produced the compacted handoff summary below\b/i,
|
|
42
|
+
// Compact/auto-clear re-seed handoff variants: these lead the FIRST user
|
|
43
|
+
// message of every post-compaction session, so titling from them painted
|
|
44
|
+
// "Re-attached after compaction…" rows in Recent (user report). Skipping
|
|
45
|
+
// them titles the session from its first REAL user message instead.
|
|
46
|
+
/^re-attached after compaction\b/i,
|
|
47
|
+
/^reference files:\s/i,
|
|
42
48
|
/^the async (?:agent|shell) task\b/i,
|
|
43
49
|
]);
|
|
44
50
|
|
|
@@ -56,8 +56,6 @@ export function createSettingsApi({
|
|
|
56
56
|
forgetDiscordToken,
|
|
57
57
|
saveTelegramToken,
|
|
58
58
|
forgetTelegramToken,
|
|
59
|
-
saveWebhookAuthtoken,
|
|
60
|
-
forgetWebhookAuthtoken,
|
|
61
59
|
setBackend,
|
|
62
60
|
}) {
|
|
63
61
|
return {
|
|
@@ -378,15 +376,5 @@ export function createSettingsApi({
|
|
|
378
376
|
scheduleBackendSave(value);
|
|
379
377
|
return { ok: true, backend: value };
|
|
380
378
|
},
|
|
381
|
-
saveWebhookAuthtoken(token) {
|
|
382
|
-
const result = saveWebhookAuthtoken(token);
|
|
383
|
-
reloadChannelsSoon();
|
|
384
|
-
return result;
|
|
385
|
-
},
|
|
386
|
-
forgetWebhookAuthtoken() {
|
|
387
|
-
const result = forgetWebhookAuthtoken();
|
|
388
|
-
reloadChannelsSoon();
|
|
389
|
-
return result;
|
|
390
|
-
},
|
|
391
379
|
};
|
|
392
380
|
}
|