mixdog 0.9.67 → 0.9.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/channels/lib/config.mjs +6 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
- package/src/runtime/channels/lib/scheduler.mjs +7 -15
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook.mjs +23 -45
- package/src/runtime/channels/lib/worker-main.mjs +45 -92
- package/src/runtime/shared/automation-attachments.mjs +72 -0
- package/src/runtime/shared/automation-workflow.mjs +41 -0
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/webhook-session-run.mjs +57 -0
- package/src/runtime/shared/webhooks-db.mjs +19 -3
- package/src/session-runtime/channel-config-api.mjs +8 -1
- package/src/session-runtime/lifecycle-api.mjs +7 -0
- package/src/session-runtime/runtime-core.mjs +103 -17
- package/src/standalone/channel-admin.mjs +36 -1
- package/src/standalone/channel-daemon-client.mjs +5 -1
- package/src/standalone/channel-daemon-transport.mjs +112 -20
- package/src/standalone/channel-daemon.mjs +6 -4
- package/src/tui/App.jsx +2 -32
- package/src/tui/app/channel-pickers.mjs +2 -143
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/dist/index.mjs +42 -169
- package/src/tui/engine/session-api-ext.mjs +32 -6
- package/src/tui/engine.mjs +14 -1
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,8 @@ import { loadConfig } from '../agent/orchestrator/config.mjs';
|
|
|
10
10
|
import { createSession } from '../agent/orchestrator/session/manager/session-lifecycle.mjs';
|
|
11
11
|
import { askSession } from '../agent/orchestrator/session/manager/ask-session.mjs';
|
|
12
12
|
import { parseScheduleModelRef } from './schedule-model-ref.mjs';
|
|
13
|
+
import { automationWorkflowOpts } from './automation-workflow.mjs';
|
|
14
|
+
import { automationPromptContent } from './automation-attachments.mjs';
|
|
13
15
|
|
|
14
16
|
/** Resolve schedule.model into a {provider,model,effort?,fast?} route. */
|
|
15
17
|
function scheduleRouteFromModelRef(modelRef, config = null) {
|
|
@@ -43,6 +45,9 @@ export async function runScheduleSession(schedule, { config = null, prompt: prom
|
|
|
43
45
|
}
|
|
44
46
|
const route = scheduleRouteFromModelRef(schedule.model, config);
|
|
45
47
|
const cwd = schedule.cwd ? String(schedule.cwd) : null;
|
|
48
|
+
// Every fire is a fresh New task (user decision): one prompt, the
|
|
49
|
+
// schedule's model/workflow/project, a brand-new session in the sidebar
|
|
50
|
+
// Automations section (newest per name wins there).
|
|
46
51
|
const session = createSession({
|
|
47
52
|
provider: route.provider,
|
|
48
53
|
model: route.model,
|
|
@@ -51,13 +56,17 @@ export async function runScheduleSession(schedule, { config = null, prompt: prom
|
|
|
51
56
|
owner: 'user',
|
|
52
57
|
sourceType: 'schedule',
|
|
53
58
|
sourceName: schedule.name,
|
|
59
|
+
sourceDelivery: schedule.delivery || null,
|
|
54
60
|
...(cwd ? { cwd } : {}),
|
|
55
61
|
desktopSession: cwd
|
|
56
62
|
? { classification: 'project', projectPath: cwd }
|
|
57
63
|
: { classification: 'task', projectPath: null },
|
|
64
|
+
...automationWorkflowOpts(schedule.workflow),
|
|
58
65
|
});
|
|
59
66
|
// The user message is the schedule's instructions verbatim: the desktop
|
|
60
67
|
// title/preview derive from it, so no "[Scheduled task: …]" header noise.
|
|
61
|
-
|
|
68
|
+
// Stored attachments ride along as composer-style content parts.
|
|
69
|
+
const content = automationPromptContent(prompt, schedule.attachments);
|
|
70
|
+
const result = await askSession(session.id, content, null, null, cwd || undefined);
|
|
62
71
|
return { sessionId: session.id, result: String(result?.content || '') };
|
|
63
72
|
}
|
|
@@ -33,6 +33,9 @@ CREATE TABLE IF NOT EXISTS scheduler.schedules (
|
|
|
33
33
|
channel_id text,
|
|
34
34
|
model text,
|
|
35
35
|
cwd text,
|
|
36
|
+
workflow text,
|
|
37
|
+
attachments jsonb,
|
|
38
|
+
delivery text,
|
|
36
39
|
prompt text NOT NULL,
|
|
37
40
|
enabled boolean NOT NULL DEFAULT true,
|
|
38
41
|
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','done')),
|
|
@@ -45,6 +48,9 @@ CREATE TABLE IF NOT EXISTS scheduler.schedules (
|
|
|
45
48
|
CONSTRAINT schedules_when_xor CHECK ((when_at IS NOT NULL) <> (when_cron IS NOT NULL))
|
|
46
49
|
);
|
|
47
50
|
ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS cwd text;
|
|
51
|
+
ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS workflow text;
|
|
52
|
+
ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS attachments jsonb;
|
|
53
|
+
ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS delivery text;
|
|
48
54
|
`;
|
|
49
55
|
|
|
50
56
|
async function getDb(dataDir = resolvePluginData()) {
|
|
@@ -82,6 +88,9 @@ function rowToDef(row) {
|
|
|
82
88
|
channelId: row.channel_id,
|
|
83
89
|
model: row.model,
|
|
84
90
|
cwd: row.cwd,
|
|
91
|
+
workflow: row.workflow,
|
|
92
|
+
attachments: row.attachments || null,
|
|
93
|
+
delivery: row.delivery || null,
|
|
85
94
|
prompt: row.prompt,
|
|
86
95
|
enabled: row.enabled,
|
|
87
96
|
status: row.status,
|
|
@@ -94,7 +103,7 @@ function rowToDef(row) {
|
|
|
94
103
|
};
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
|
|
106
|
+
const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, workflow, attachments, delivery, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
|
|
98
107
|
|
|
99
108
|
// ---------------------------------------------------------------------------
|
|
100
109
|
// Public API
|
|
@@ -130,6 +139,9 @@ export async function upsertSchedule(def, { dataDir } = {}) {
|
|
|
130
139
|
def.channelId ?? null,
|
|
131
140
|
def.model ?? null,
|
|
132
141
|
def.cwd ?? null,
|
|
142
|
+
def.workflow ?? null,
|
|
143
|
+
def.attachments ? JSON.stringify(def.attachments) : null,
|
|
144
|
+
def.delivery ?? null,
|
|
133
145
|
def.prompt,
|
|
134
146
|
def.enabled ?? true,
|
|
135
147
|
def.status ?? 'active',
|
|
@@ -137,8 +149,8 @@ export async function upsertSchedule(def, { dataDir } = {}) {
|
|
|
137
149
|
];
|
|
138
150
|
const { rows } = await db.query(
|
|
139
151
|
`INSERT INTO scheduler.schedules
|
|
140
|
-
(name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, prompt, enabled, status, next_fire_at)
|
|
141
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
152
|
+
(name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, workflow, attachments, delivery, prompt, enabled, status, next_fire_at)
|
|
153
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
|
142
154
|
ON CONFLICT (name) DO UPDATE SET
|
|
143
155
|
description = EXCLUDED.description,
|
|
144
156
|
when_at = EXCLUDED.when_at,
|
|
@@ -148,6 +160,9 @@ export async function upsertSchedule(def, { dataDir } = {}) {
|
|
|
148
160
|
channel_id = EXCLUDED.channel_id,
|
|
149
161
|
model = EXCLUDED.model,
|
|
150
162
|
cwd = EXCLUDED.cwd,
|
|
163
|
+
workflow = EXCLUDED.workflow,
|
|
164
|
+
attachments = EXCLUDED.attachments,
|
|
165
|
+
delivery = EXCLUDED.delivery,
|
|
151
166
|
prompt = EXCLUDED.prompt,
|
|
152
167
|
enabled = EXCLUDED.enabled,
|
|
153
168
|
next_fire_at = EXCLUDED.next_fire_at,
|
|
@@ -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()
|
|
@@ -17,7 +17,7 @@ import { runScheduleSession } from '../runtime/shared/schedule-session-run.mjs';
|
|
|
17
17
|
// API object; the mutating admin helpers are imported directly here and the
|
|
18
18
|
// runtime injects only the closure-owned callbacks (backend flush, channel
|
|
19
19
|
// worker handle, soft reload).
|
|
20
|
-
export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon }) {
|
|
20
|
+
export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon, ensureAutomationRuntime = () => {} }) {
|
|
21
21
|
return {
|
|
22
22
|
async getChannelSetup() {
|
|
23
23
|
// Flush a pending debounced backend switch first so setup readers
|
|
@@ -42,6 +42,10 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
|
|
|
42
42
|
async saveSchedule(entry) {
|
|
43
43
|
const result = await saveSchedule(entry);
|
|
44
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();
|
|
45
49
|
return result;
|
|
46
50
|
},
|
|
47
51
|
async deleteSchedule(name) {
|
|
@@ -52,11 +56,13 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
|
|
|
52
56
|
async setScheduleEnabled(name, enabled) {
|
|
53
57
|
const result = await setScheduleEnabled(name, enabled);
|
|
54
58
|
reloadChannelsSoon();
|
|
59
|
+
if (enabled !== false) ensureAutomationRuntime();
|
|
55
60
|
return result;
|
|
56
61
|
},
|
|
57
62
|
async saveWebhook(entry) {
|
|
58
63
|
const result = await saveWebhook(entry);
|
|
59
64
|
reloadChannelsSoon();
|
|
65
|
+
ensureAutomationRuntime();
|
|
60
66
|
return result;
|
|
61
67
|
},
|
|
62
68
|
async deleteWebhook(name) {
|
|
@@ -67,6 +73,7 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
|
|
|
67
73
|
async setWebhookEnabled(name, enabled) {
|
|
68
74
|
const result = await setWebhookEnabled(name, enabled);
|
|
69
75
|
reloadChannelsSoon();
|
|
76
|
+
if (enabled !== false) ensureAutomationRuntime();
|
|
70
77
|
return result;
|
|
71
78
|
},
|
|
72
79
|
// Read-only secret fetch for the webhook editor; no worker reload.
|
|
@@ -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 || '';
|
|
@@ -106,6 +109,10 @@ export function createLifecycleApi(deps) {
|
|
|
106
109
|
// after a turn had already finished.
|
|
107
110
|
heartbeatAt: Number(heartbeatMtimes.get(s.id)) || 0,
|
|
108
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,
|
|
109
116
|
};
|
|
110
117
|
}).filter(Boolean);
|
|
111
118
|
};
|
|
@@ -329,6 +329,11 @@ export async function createMixdogSessionRuntime({
|
|
|
329
329
|
// past its remoteEnabled guards without prematurely showing this session as
|
|
330
330
|
// remote (single-holder: a live owner must not be stolen by autoStart).
|
|
331
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;
|
|
332
337
|
// Remote-mode transcript writer (Discord outbound). Lazily created per
|
|
333
338
|
// session.id + cwd inside ask(); only active while remoteEnabled.
|
|
334
339
|
let _transcriptWriter = null;
|
|
@@ -1032,6 +1037,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1032
1037
|
// would re-fork/activate). Idempotent: no-op when already enabled.
|
|
1033
1038
|
if (msg?.params?.state === 'acquired' && !remoteEnabled) {
|
|
1034
1039
|
remoteEnabled = true;
|
|
1040
|
+
if (!remoteSessionId) remoteSessionId = session?.id || null;
|
|
1035
1041
|
ensureRemoteTranscriptWriter();
|
|
1036
1042
|
// Auto-acquire: the worker restored yesterday's transcript from
|
|
1037
1043
|
// persisted status and we just created the CURRENT writer. Push the
|
|
@@ -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
|
|
@@ -1937,23 +1956,63 @@ export async function createMixdogSessionRuntime({
|
|
|
1937
1956
|
else process.env.MIXDOG_REMOTE_INTENT = _prevIntent;
|
|
1938
1957
|
}
|
|
1939
1958
|
if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
|
|
1940
|
-
// Explicit
|
|
1941
|
-
//
|
|
1942
|
-
//
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
+
});
|
|
1948
1968
|
// Claim attempt dispatched; the worker's acquire/supersede notification
|
|
1949
1969
|
// now owns the remoteEnabled transition. Drop the transient marker.
|
|
1950
1970
|
remoteClaimPending = false;
|
|
1951
|
-
})().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
|
+
});
|
|
1952
1981
|
return true;
|
|
1953
1982
|
}
|
|
1954
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
|
+
|
|
1955
2013
|
function stopRemote(reason) {
|
|
1956
2014
|
remoteEnabled = false;
|
|
2015
|
+
remoteSessionId = null;
|
|
1957
2016
|
// A pending auto-claim is abandoned by an explicit stop/supersede.
|
|
1958
2017
|
remoteClaimPending = false;
|
|
1959
2018
|
// Cancel any pending deferred start so it can't fire after remote is off.
|
|
@@ -2008,8 +2067,11 @@ export async function createMixdogSessionRuntime({
|
|
|
2008
2067
|
// Channels are opt-in: only boot the worker when this session started in (or
|
|
2009
2068
|
// was toggled into) remote mode. Non-remote sessions never contend for the
|
|
2010
2069
|
// channel; see startRemote()/stopRemote() and the `/remote` toggle.
|
|
2011
|
-
// `remote.autoStart` in mixdog-config.json makes
|
|
2012
|
-
// 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.
|
|
2013
2075
|
// The flag lives in the TOP-LEVEL `remote` section of mixdog-config.json
|
|
2014
2076
|
// (sibling of agent/ui/channels), not inside the agent section that
|
|
2015
2077
|
// cfgMod.loadConfig returns — read it via the shared whole-file reader.
|
|
@@ -2022,7 +2084,7 @@ export async function createMixdogSessionRuntime({
|
|
|
2022
2084
|
// known to have won). Track it as a separate deferred auto request; the
|
|
2023
2085
|
// worker's acquire notification flips remoteEnabled if/when it wins the seat.
|
|
2024
2086
|
let remoteAutoStartRequested = false;
|
|
2025
|
-
if (!remoteEnabled) {
|
|
2087
|
+
if (!remoteEnabled && !desktopSession) {
|
|
2026
2088
|
try {
|
|
2027
2089
|
if (config?.remote?.autoStart === true
|
|
2028
2090
|
|| sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
|
|
@@ -2054,9 +2116,9 @@ export async function createMixdogSessionRuntime({
|
|
|
2054
2116
|
prewarmTimers.channelStartTimer.unref?.();
|
|
2055
2117
|
} else {
|
|
2056
2118
|
// Automation decoupling (user decision): enabled schedules/webhooks boot
|
|
2057
|
-
// the worker on their own — no remote flag, no remote.autoStart, no
|
|
2058
|
-
// messaging backend
|
|
2059
|
-
//
|
|
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.
|
|
2060
2122
|
prewarmTimers.channelStartTimer = setTimeout(() => {
|
|
2061
2123
|
prewarmTimers.channelStartTimer = null;
|
|
2062
2124
|
if (closeRequested || remoteEnabled) return;
|
|
@@ -2064,7 +2126,7 @@ export async function createMixdogSessionRuntime({
|
|
|
2064
2126
|
.then((active) => {
|
|
2065
2127
|
if (!active || closeRequested || remoteEnabled) return;
|
|
2066
2128
|
bootProfile('channels:automation-autostart');
|
|
2067
|
-
|
|
2129
|
+
void invokeChannelStart();
|
|
2068
2130
|
})
|
|
2069
2131
|
.catch(() => { /* automation probe is best-effort */ });
|
|
2070
2132
|
}, remoteAutoStartDelayMs);
|
|
@@ -2127,7 +2189,14 @@ export async function createMixdogSessionRuntime({
|
|
|
2127
2189
|
setBackend,
|
|
2128
2190
|
});
|
|
2129
2191
|
|
|
2130
|
-
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
|
+
});
|
|
2131
2200
|
const providerAuthApi = createProviderAuthApi({
|
|
2132
2201
|
cfgMod,
|
|
2133
2202
|
getConfig: () => config,
|
|
@@ -2404,9 +2473,26 @@ export async function createMixdogSessionRuntime({
|
|
|
2404
2473
|
stopRemote(reason) {
|
|
2405
2474
|
return stopRemote(reason);
|
|
2406
2475
|
},
|
|
2476
|
+
releaseRemote(reason) {
|
|
2477
|
+
return releaseRemote(reason);
|
|
2478
|
+
},
|
|
2407
2479
|
isRemoteEnabled() {
|
|
2408
2480
|
return isRemoteEnabled();
|
|
2409
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
|
+
},
|
|
2410
2496
|
// Subscribe to non-user-initiated remote flips (seat superseded). Returns
|
|
2411
2497
|
// an unsubscribe function. TUI uses this to sync its Remote indicator and
|
|
2412
2498
|
// show a "remote taken over" notice.
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
deleteEndpoint as dbDeleteEndpoint,
|
|
39
39
|
setEndpointEnabled as dbSetEndpointEnabled,
|
|
40
40
|
} from '../runtime/shared/webhooks-db.mjs';
|
|
41
|
+
import { normalizeAutomationAttachments } from '../runtime/shared/automation-attachments.mjs';
|
|
41
42
|
import { readHookPublicBase } from '../runtime/channels/lib/webhook/relay-tunnel.mjs';
|
|
42
43
|
|
|
43
44
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -358,6 +359,11 @@ function scheduleToDisplay(s) {
|
|
|
358
359
|
channel: s.channelId || undefined,
|
|
359
360
|
model: s.model || undefined,
|
|
360
361
|
cwd: s.cwd || undefined,
|
|
362
|
+
workflow: s.workflow || undefined,
|
|
363
|
+
attachments: s.attachments || undefined,
|
|
364
|
+
// Delivery mode: legacy channel-target rows (pre-delivery) behaved as
|
|
365
|
+
// relay+visible, which maps to 'both'.
|
|
366
|
+
delivery: s.delivery || (s.target === 'channel' ? 'both' : 'app'),
|
|
361
367
|
enabled: s.enabled !== false,
|
|
362
368
|
instructions: s.prompt,
|
|
363
369
|
route: s.target === 'channel' ? `channel:${s.channelId}` : 'session',
|
|
@@ -383,6 +389,9 @@ export async function saveSchedule({
|
|
|
383
389
|
channel,
|
|
384
390
|
model,
|
|
385
391
|
cwd,
|
|
392
|
+
workflow,
|
|
393
|
+
attachments,
|
|
394
|
+
delivery,
|
|
386
395
|
enabled,
|
|
387
396
|
instructions,
|
|
388
397
|
overwrite = false,
|
|
@@ -391,6 +400,11 @@ export async function saveSchedule({
|
|
|
391
400
|
const body = String(instructions || '').trim();
|
|
392
401
|
if (!body) throw new Error('schedule instructions are required');
|
|
393
402
|
if (channel && !model) throw new Error('model is required when channel is set');
|
|
403
|
+
// 'app' → session-only; 'channel'/'both' → the run result relays to the
|
|
404
|
+
// main channel (target 'channel', channelId resolved at fire time).
|
|
405
|
+
const mode = ['app', 'channel', 'both'].includes(String(delivery || '').trim())
|
|
406
|
+
? String(delivery).trim()
|
|
407
|
+
: (channel ? 'both' : 'app');
|
|
394
408
|
const hasTime = time != null && String(time).trim() !== '';
|
|
395
409
|
const hasAt = at != null && String(at).trim() !== '';
|
|
396
410
|
if (hasTime && hasAt) throw new Error('provide either `time` (recurring) or `at` (one-shot), not both');
|
|
@@ -406,10 +420,13 @@ export async function saveSchedule({
|
|
|
406
420
|
whenCron,
|
|
407
421
|
whenAt,
|
|
408
422
|
timezone: timezone ? String(timezone).trim() : null,
|
|
409
|
-
target:
|
|
423
|
+
target: mode === 'app' ? 'session' : 'channel',
|
|
410
424
|
channelId: channel ? String(channel).trim() : null,
|
|
411
425
|
model: model ? String(model).trim() : null,
|
|
412
426
|
cwd: cwd ? String(cwd).trim() : null,
|
|
427
|
+
workflow: workflow ? String(workflow).trim() : null,
|
|
428
|
+
attachments: normalizeAutomationAttachments(attachments),
|
|
429
|
+
delivery: mode,
|
|
413
430
|
prompt: body,
|
|
414
431
|
enabled: enabled !== false,
|
|
415
432
|
});
|
|
@@ -441,6 +458,10 @@ async function listWebhooks() {
|
|
|
441
458
|
parser: ep.parser || 'github',
|
|
442
459
|
...(ep.channelId ? { channel: ep.channelId } : {}),
|
|
443
460
|
...(ep.model ? { model: ep.model } : {}),
|
|
461
|
+
...(ep.cwd ? { cwd: ep.cwd } : {}),
|
|
462
|
+
...(ep.workflow ? { workflow: ep.workflow } : {}),
|
|
463
|
+
...(ep.attachments ? { attachments: ep.attachments } : {}),
|
|
464
|
+
delivery: ep.delivery || 'app',
|
|
444
465
|
enabled: ep.enabled,
|
|
445
466
|
// The store never projects the plaintext secret through list paths; it
|
|
446
467
|
// exposes a presence flag (secretSet) instead.
|
|
@@ -458,6 +479,10 @@ export async function saveWebhook({
|
|
|
458
479
|
secret,
|
|
459
480
|
channel,
|
|
460
481
|
model,
|
|
482
|
+
cwd,
|
|
483
|
+
workflow,
|
|
484
|
+
attachments,
|
|
485
|
+
delivery,
|
|
461
486
|
enabled,
|
|
462
487
|
instructions,
|
|
463
488
|
overwrite = false,
|
|
@@ -486,6 +511,12 @@ export async function saveWebhook({
|
|
|
486
511
|
parser: nextParser,
|
|
487
512
|
channelId: channel ? String(channel).trim() : null,
|
|
488
513
|
model: model ? String(model).trim() : null,
|
|
514
|
+
cwd: cwd ? String(cwd).trim() : null,
|
|
515
|
+
workflow: workflow ? String(workflow).trim() : null,
|
|
516
|
+
attachments: normalizeAutomationAttachments(attachments),
|
|
517
|
+
delivery: ['app', 'channel', 'both'].includes(String(delivery || '').trim())
|
|
518
|
+
? String(delivery).trim()
|
|
519
|
+
: 'app',
|
|
489
520
|
secret: secretValue,
|
|
490
521
|
instructions: body,
|
|
491
522
|
enabled: enabled !== false,
|
|
@@ -496,6 +527,10 @@ export async function saveWebhook({
|
|
|
496
527
|
parser: saved.parser,
|
|
497
528
|
...(saved.channelId ? { channel: saved.channelId } : {}),
|
|
498
529
|
...(saved.model ? { model: saved.model } : {}),
|
|
530
|
+
...(saved.cwd ? { cwd: saved.cwd } : {}),
|
|
531
|
+
...(saved.workflow ? { workflow: saved.workflow } : {}),
|
|
532
|
+
...(saved.attachments ? { attachments: saved.attachments } : {}),
|
|
533
|
+
delivery: saved.delivery || 'app',
|
|
499
534
|
...(enabled === false ? { enabled: false } : {}),
|
|
500
535
|
secret: secretValue,
|
|
501
536
|
instructions: body,
|
|
@@ -147,7 +147,11 @@ export async function attachToDaemon({
|
|
|
147
147
|
token: serverToken,
|
|
148
148
|
method: 'POST',
|
|
149
149
|
path: '/client/register',
|
|
150
|
-
|
|
150
|
+
// Initial attachment is transport-only. Mark it with the legacy
|
|
151
|
+
// non-stealing registration intent as well as relying on the current
|
|
152
|
+
// daemon contract, so a newly updated desktop cannot supersede a
|
|
153
|
+
// terminal still served by an older machine-global daemon.
|
|
154
|
+
body: { leadPid, cwd, reattach: true },
|
|
151
155
|
timeoutMs: 3000,
|
|
152
156
|
});
|
|
153
157
|
} catch (err) {
|