mixdog 0.9.67 → 0.9.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  3. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  4. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  5. package/src/runtime/channels/lib/config.mjs +6 -5
  6. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  7. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  8. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  9. package/src/runtime/channels/lib/webhook.mjs +23 -45
  10. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  11. package/src/runtime/shared/automation-attachments.mjs +72 -0
  12. package/src/runtime/shared/automation-workflow.mjs +41 -0
  13. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  14. package/src/runtime/shared/schedules-db.mjs +18 -3
  15. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  16. package/src/runtime/shared/webhooks-db.mjs +19 -3
  17. package/src/session-runtime/channel-config-api.mjs +8 -1
  18. package/src/session-runtime/lifecycle-api.mjs +7 -0
  19. package/src/session-runtime/runtime-core.mjs +103 -17
  20. package/src/standalone/channel-admin.mjs +36 -1
  21. package/src/standalone/channel-daemon-client.mjs +5 -1
  22. package/src/standalone/channel-daemon-transport.mjs +112 -20
  23. package/src/standalone/channel-daemon.mjs +6 -4
  24. package/src/tui/App.jsx +2 -32
  25. package/src/tui/app/channel-pickers.mjs +2 -143
  26. package/src/tui/app/slash-commands.mjs +1 -3
  27. package/src/tui/app/slash-dispatch.mjs +3 -3
  28. package/src/tui/dist/index.mjs +42 -169
  29. package/src/tui/engine/session-api-ext.mjs +32 -6
  30. package/src/tui/engine.mjs +14 -1
@@ -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
- const result = await askSession(session.id, prompt, null, null, cwd || undefined);
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 start: unconditional claim + forwarder rebind (last-wins seat
1941
- // overwrite + transcript rebind onto this session). AUTO start SKIPS this
1942
- // — the freshly-forked worker already ran its claim-if-vacant boot claim,
1943
- // and forcing activate here would steal a live owner that autoStart is
1944
- // meant to yield to. The worker's acquire notification drives remote ON.
1945
- if (intent !== 'auto') {
1946
- await channels.execute('activate_channel_bridge', { active: true });
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) => { remoteClaimPending = false; bootProfile('channels:claim-failed', { error: error?.message || String(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 every session claim remote
2012
- // at boot (same force-takeover semantics as `mixdog --remote` / `/remote`).
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 required. Claim-if-vacant auto intent reuses the
2059
- // exact autoStart semantics (a live owner elsewhere wins silently).
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
- startRemote({ intent: 'auto' });
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({ flushBackendSave, channels, reloadChannelsSoon });
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: channel ? 'channel' : 'session',
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
- body: { leadPid, cwd },
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) {