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.
Files changed (45) 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 +5 -0
  4. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +20 -2
  5. package/src/runtime/channels/lib/config.mjs +13 -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/status-snapshot.mjs +6 -30
  9. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  10. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
  11. package/src/runtime/channels/lib/webhook.mjs +46 -246
  12. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  13. package/src/runtime/shared/automation-attachments.mjs +72 -0
  14. package/src/runtime/shared/automation-workflow.mjs +41 -0
  15. package/src/runtime/shared/config.mjs +0 -9
  16. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  17. package/src/runtime/shared/schedules-db.mjs +18 -3
  18. package/src/runtime/shared/time-format.mjs +56 -0
  19. package/src/runtime/shared/tool-card-model.mjs +740 -0
  20. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  21. package/src/runtime/shared/webhooks-db.mjs +19 -3
  22. package/src/session-runtime/channel-config-api.mjs +13 -1
  23. package/src/session-runtime/lifecycle-api.mjs +12 -0
  24. package/src/session-runtime/prewarm.mjs +13 -7
  25. package/src/session-runtime/runtime-core.mjs +127 -22
  26. package/src/session-runtime/session-text.mjs +6 -0
  27. package/src/session-runtime/settings-api.mjs +0 -12
  28. package/src/standalone/channel-admin.mjs +71 -22
  29. package/src/standalone/channel-daemon-client.mjs +5 -1
  30. package/src/standalone/channel-daemon-transport.mjs +112 -20
  31. package/src/standalone/channel-daemon.mjs +6 -4
  32. package/src/tui/App.jsx +2 -47
  33. package/src/tui/app/channel-pickers.mjs +8 -173
  34. package/src/tui/app/doctor.mjs +0 -1
  35. package/src/tui/app/slash-commands.mjs +1 -3
  36. package/src/tui/app/slash-dispatch.mjs +3 -3
  37. package/src/tui/components/ToolExecution.jsx +47 -196
  38. package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
  39. package/src/tui/components/tool-execution/text-format.mjs +27 -104
  40. package/src/tui/dist/index.mjs +340 -346
  41. package/src/tui/engine/live-share.mjs +9 -0
  42. package/src/tui/engine/session-api-ext.mjs +32 -16
  43. package/src/tui/engine.mjs +14 -1
  44. package/src/tui/time-format.mjs +4 -51
  45. 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
- forgetWebhookAuthtoken,
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 start: unconditional claim + forwarder rebind (last-wins seat
1937
- // overwrite + transcript rebind onto this session). AUTO start SKIPS this
1938
- // — the freshly-forked worker already ran its claim-if-vacant boot claim,
1939
- // and forcing activate here would steal a live owner that autoStart is
1940
- // meant to yield to. The worker's acquire notification drives remote ON.
1941
- if (intent !== 'auto') {
1942
- await channels.execute('activate_channel_bridge', { active: true });
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) => { 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
+ });
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 every session claim remote
2008
- // 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.
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({ 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
+ });
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
  }