mixdog 1.1.1 → 1.1.2

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 (37) hide show
  1. package/package.json +1 -2
  2. package/scripts/release-paths.mjs +39 -2
  3. package/src/headless-role.mjs +2 -2
  4. package/src/lib/rules-builder.cjs +0 -23
  5. package/src/rules/lead/LEAD.md +2 -2
  6. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -5
  7. package/src/runtime/agent/orchestrator/session/context-utils.mjs +12 -14
  8. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -5
  9. package/src/runtime/agent/orchestrator/session/read-dedup.mjs +0 -1
  10. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +0 -10
  11. package/src/runtime/channels/index.mjs +1 -5
  12. package/src/runtime/channels/lib/runtime-paths.mjs +8 -23
  13. package/src/runtime/channels/lib/tool-dispatch.mjs +1 -13
  14. package/src/runtime/channels/lib/worker-ipc.mjs +2 -2
  15. package/src/runtime/channels/lib/worker-main.mjs +4 -34
  16. package/src/runtime/shared/semver.d.mts +1 -0
  17. package/src/runtime/shared/semver.mjs +57 -0
  18. package/src/runtime/shared/tool-surface.mjs +2 -2
  19. package/src/runtime/shared/update-checker.mjs +2 -31
  20. package/src/session-runtime/boot/begin.mjs +0 -2
  21. package/src/session-runtime/boot/tools.mjs +5 -4
  22. package/src/session-runtime/resource-mcp-api.mjs +7 -3
  23. package/src/session-runtime/runtime-bootstrap.mjs +0 -4
  24. package/src/session-runtime/runtime-paths.mjs +0 -1
  25. package/src/session-runtime/services/channel-worker.mjs +1 -1
  26. package/src/session-runtime/settings-profile-api.mjs +27 -2
  27. package/src/session-runtime/starter-guide-state.mjs +62 -0
  28. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -2
  29. package/src/standalone/channel-binding.mjs +1 -2
  30. package/src/standalone/channel-transport/call-dispatch.mjs +4 -10
  31. package/src/standalone/daemon-channels-loader.mjs +2 -3
  32. package/src/standalone/local-session-runtime.mjs +2 -78
  33. package/src/standalone/session-protocol.mjs +1 -0
  34. package/src/tui/session/session-api/extensions.mjs +12 -8
  35. package/src/tui/session/session-api/integrations.mjs +1 -0
  36. package/src/tui/session/session-api/shared.mjs +10 -6
  37. package/src/runtime/channels/tool-defs.mjs +0 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Open-source coding agent that matches Codex on Terminal-Bench 2.1 with 63% fewer tokens.",
@@ -203,7 +203,6 @@
203
203
  "eslint-scope": "^9.1.2",
204
204
  "lucide-static": "^1.41.0"
205
205
  },
206
- "main": "index.js",
207
206
  "directories": {
208
207
  "doc": "docs"
209
208
  },
@@ -2,7 +2,7 @@
2
2
  // Single source of truth for CI/CD path selection.
3
3
  //
4
4
  // Consumers:
5
- // - .github/workflows/release-gate.yml → `node scripts/release-paths.mjs desktop-regex|runtime-regex`
5
+ // - .github/workflows/release-gate.yml → `node scripts/release-paths.mjs desktop-regex|runtime-regex|patch-regex|history-regex|mobile-regex`
6
6
  // (grep -E patterns selecting which incremental gates a change requires)
7
7
  // - .github/workflows/deploy.yml → `import { RELEASE_CRITICAL_PATHS }`
8
8
  // (git diff paths whose change invalidates the previous release's
@@ -47,6 +47,24 @@ const PATCH_GATE_FILES = [
47
47
  ...GATE_SELECTION_SOURCES,
48
48
  ];
49
49
 
50
+ // The shipped-definition history is generated from git history of the agent,
51
+ // workflow, output-style and bundled-skill trees (scripts/build-shipped-history.mjs)
52
+ // and committed under src/defaults/. Release re-checks it, so the gate checks
53
+ // it first instead of letting a stale file fail the deploy late.
54
+ const HISTORY_GATE_PREFIXES = ['src/agents/', 'src/workflows/', 'src/output-styles/', 'src/defaults/'];
55
+ const HISTORY_GATE_FILES = [
56
+ 'scripts/build-shipped-history.mjs',
57
+ 'src/runtime/shared/shipped-definitions.mjs',
58
+ '.github/workflows/release-gate.yml',
59
+ ...GATE_SELECTION_SOURCES,
60
+ ];
61
+
62
+ // The mobile release scripts (Python) carry unittest suites that no Node lane
63
+ // discovers; they run when those scripts or the mobile workflows change.
64
+ const MOBILE_GATE_PREFIXES = ['apps/mobile/scripts/'];
65
+ const MOBILE_WORKFLOW_PATTERN = '\\.github/workflows/mobile-[^/]*\\.yml$';
66
+ const MOBILE_GATE_FILES = ['.github/workflows/release-gate.yml', ...GATE_SELECTION_SOURCES];
67
+
50
68
  // deploy.yml plan: paths whose change since the published release tag marks
51
69
  // the default lane as unverified.
52
70
  export const RELEASE_CRITICAL_PATHS = ['native', 'scripts', 'src', ...PACKAGE_MANIFESTS];
@@ -77,14 +95,33 @@ export function patchGateRegex() {
77
95
  ].join('|')})`;
78
96
  }
79
97
 
98
+ export function historyGateRegex() {
99
+ return `^(${[
100
+ ...HISTORY_GATE_PREFIXES.map(escapeRegex),
101
+ ...HISTORY_GATE_FILES.map((file) => `${escapeRegex(file)}$`),
102
+ ].join('|')})`;
103
+ }
104
+
105
+ export function mobileGateRegex() {
106
+ return `^(${[
107
+ ...MOBILE_GATE_PREFIXES.map(escapeRegex),
108
+ ...MOBILE_GATE_FILES.map((file) => `${escapeRegex(file)}$`),
109
+ MOBILE_WORKFLOW_PATTERN,
110
+ ].join('|')})`;
111
+ }
112
+
80
113
  const mode = process.argv[2];
81
114
  if (mode) {
82
115
  if (mode === 'desktop-regex') process.stdout.write(`${desktopGateRegex()}\n`);
83
116
  else if (mode === 'runtime-regex') process.stdout.write(`${runtimeGateRegex()}\n`);
84
117
  else if (mode === 'patch-regex') process.stdout.write(`${patchGateRegex()}\n`);
118
+ else if (mode === 'history-regex') process.stdout.write(`${historyGateRegex()}\n`);
119
+ else if (mode === 'mobile-regex') process.stdout.write(`${mobileGateRegex()}\n`);
85
120
  else if (mode === 'critical-paths') process.stdout.write(`${RELEASE_CRITICAL_PATHS.join('\n')}\n`);
86
121
  else {
87
- process.stderr.write(`unknown mode: ${mode} (expected desktop-regex|runtime-regex|patch-regex|critical-paths)\n`);
122
+ process.stderr.write(
123
+ `unknown mode: ${mode} (expected desktop-regex|runtime-regex|patch-regex|history-regex|mobile-regex|critical-paths)\n`
124
+ );
88
125
  process.exit(1);
89
126
  }
90
127
  }
@@ -140,8 +140,8 @@ export async function runHeadlessRole({
140
140
  } = {}) {
141
141
  const cleanAgent = clean(agent);
142
142
  const cleanMessage = clean(message);
143
- // Classic headless surface: web search and memory tools start OFF
144
- // and opt back in per run (--web-search / --memory). An explicit
143
+ // Web search and memory start OFF. This call can pass webSearch or memory;
144
+ // exec accepts --web-search and rejects --memory. An explicit
145
145
  // MIXDOG_FEATURE_* value from the caller environment always wins.
146
146
  for (const [key, enabled] of [
147
147
  ['MIXDOG_FEATURE_WEB_SEARCH', webSearch],
@@ -9,7 +9,6 @@
9
9
  * - buildAgentRoleContent — BP3: agent role/system rules
10
10
  * - buildAgentRetrievalInjectionContent — BP3: narrow read-only retrieval role
11
11
  * - buildLeadMetaContent — BP2: Lead profile/settings context
12
- * - buildInjectionContent — legacy joined Lead session content
13
12
  *
14
13
  * 4-BP cache layout (composeSystemPrompt):
15
14
  * BP1 = shared tool policy
@@ -400,27 +399,6 @@ function buildLeadLanguageContent({ DATA_DIR }) {
400
399
  return buildLanguageSection(DATA_DIR);
401
400
  }
402
401
 
403
- /**
404
- * Build the Lead injection content.
405
- */
406
- function buildInjectionContent({ PLUGIN_ROOT, DATA_DIR }) {
407
- const parts = [];
408
-
409
- const tool = buildSharedToolContent({ PLUGIN_ROOT });
410
- if (tool) parts.push(tool);
411
-
412
- const meta = buildLeadMetaContent({ PLUGIN_ROOT, DATA_DIR });
413
- if (meta) parts.push(meta);
414
-
415
- const role = buildLeadRoleContent({ PLUGIN_ROOT, DATA_DIR });
416
- if (role) parts.push(role);
417
-
418
- const language = buildLeadLanguageContent({ DATA_DIR });
419
- if (language) parts.push(language);
420
-
421
- return parts.join('\n\n');
422
- }
423
-
424
402
  function buildAgentRoleContent({ PLUGIN_ROOT, profile = 'full' }) {
425
403
  const AGENT_DIR = path.join(PLUGIN_ROOT, 'rules', 'agent');
426
404
  if (String(profile || 'full') === 'retrieval') {
@@ -455,7 +433,6 @@ module.exports = {
455
433
  buildLeadMetaContent,
456
434
  buildLeadLanguageContent,
457
435
  buildAgentRoleContent,
458
- buildInjectionContent,
459
436
  buildAgentRetrievalInjectionContent,
460
437
  omitToolRoutes,
461
438
  };
@@ -10,8 +10,8 @@
10
10
  never per-call narration or plan repeats.
11
11
  - Mid-task: a replacement supersedes, an addition folds in, a status question
12
12
  gets a brief answer while work continues.
13
- - Context is managed automatically: never propose stopping or stop on your own
14
- judgment.
13
+ - Context is managed automatically: never stop or propose stopping because of
14
+ context size.
15
15
  - Be a grounded, candid, and attentive collaborator: natural and respectful,
16
16
  without flattery, forced humor, mimicry, or stereotyping.
17
17
  <!-- tools: agent -->
@@ -98,8 +98,7 @@ const admitted = (tools, names) => (tools || []).filter((tool) => names.includes
98
98
 
99
99
  /**
100
100
  * Assemble this runtime's tool definitions from the loaded tool-def modules.
101
- * Pure: callers resolve their own availability flags (skills env gate, channel
102
- * ownership) and pass the already-built goal/agent tool arrays.
101
+ * Pure: callers resolve their own availability flags (skills env gate) and pass the already-built goal/agent tool arrays.
103
102
  *
104
103
  * - `standaloneTools` is the model-facing set (also the deferred catalog base).
105
104
  * - `internalToolDefs` adds the `public: false` web-search tools: reachable
@@ -108,7 +107,6 @@ const admitted = (tools, names) => (tools || []).filter((tool) => names.includes
108
107
  export function collectStandaloneToolDefs({
109
108
  webSearchToolDefs,
110
109
  memoryToolDefs,
111
- channelToolDefs,
112
110
  codeGraphToolDefs,
113
111
  browserToolDefs = [],
114
112
  computerToolDefs = [],
@@ -119,7 +117,6 @@ export function collectStandaloneToolDefs({
119
117
  setupToolDefs = [],
120
118
  goalTools = [],
121
119
  agentTools = [],
122
- isChannelTool = () => false,
123
120
  skillToolEnabled = true,
124
121
  }) {
125
122
  const webSearchRuntimeTools = admitted(webSearchToolDefs?.TOOL_DEFS, WEB_SEARCH_RUNTIME_TOOLS);
@@ -129,7 +126,6 @@ export function collectStandaloneToolDefs({
129
126
  CWD_TOOL,
130
127
  ...webSearchRuntimeTools.filter((tool) => tool?.public !== false),
131
128
  ...admitted(memoryToolDefs?.TOOL_DEFS, MEMORY_RUNTIME_TOOLS),
132
- ...(channelToolDefs?.TOOL_DEFS || []).filter((tool) => isChannelTool(tool?.name)),
133
129
  ...admitted(codeGraphToolDefs?.CODE_GRAPH_TOOL_DEFS, CODE_GRAPH_RUNTIME_TOOLS),
134
130
  ...admitted(browserToolDefs, BROWSER_RUNTIME_TOOLS),
135
131
  ...admitted(computerToolDefs, COMPUTER_RUNTIME_TOOLS),
@@ -1,6 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { estimateTokens } from './token-estimate.mjs';
3
- import { beginIdentity, endIdentity, putJsonElement, putJsonString, putRaw, putShapeText } from './context-identity-writer.mjs';
3
+ import {
4
+ beginIdentity,
5
+ endIdentity,
6
+ putJsonElement,
7
+ putJsonString,
8
+ putRaw,
9
+ putShapeText,
10
+ } from './context-identity-writer.mjs';
4
11
  import { positiveInt } from '../../../shared/numbers.mjs';
5
12
  import { hasPlainPrototype } from '../../../shared/object.mjs';
6
13
  import { createContextFingerprinter } from './context-fingerprint.mjs';
@@ -39,19 +46,10 @@ export {
39
46
  resolveSessionCompactPolicy,
40
47
  } from './context-compaction-policy.mjs';
41
48
 
42
- // ---------------------------------------------------------------------------
43
- // Token estimation lives in ./token-estimate.mjs — a conservative
44
- // Unicode-aware heuristic. It is accurate ENOUGH because context pressure is
45
- // anchored on the provider usage baseline (the exact prompt-token count the
46
- // provider reports for the previous request) and the heuristic only prices the
47
- // delta appended since then. Measured across 5,552 real deltas the worst
48
- // underestimate was 2,985 tokens (1.5% of a 200K window) with a +6.5%
49
- // aggregate bias toward overcounting.
50
- //
51
- // providerTokenCalibration() below reconciles that estimate with actual
52
- // billing at the provider-aware aggregation boundary (compaction pressure /
53
- // context gauge), never inside the provider-agnostic per-message memo.
54
- // ---------------------------------------------------------------------------
49
+ // providerTokenCalibration() below reconciles the ./token-estimate.mjs
50
+ // heuristic with actual billing at the provider-aware aggregation boundary
51
+ // (compaction pressure / context gauge), never inside the provider-agnostic
52
+ // per-message memo.
55
53
 
56
54
  // Billed-prompt / estimate ratio per provider family. Env overrides let a
57
55
  // deployment recalibrate without a code change; values are clamped to a
@@ -1,9 +1,5 @@
1
1
  // src/runtime/agent/orchestrator/session/manager.mjs
2
- // Thin facade over the split session-manager modules in ./manager/. This file
3
- // re-exports the EXACT public surface the previous monolith exposed so every
4
- // importer (loop.mjs, loop/tool-exec.mjs, agent-dispatch.mjs, session-builder,
5
- // abort-lookup, statusline-agents, headless-role, session-runtime, and the
6
- // smoke scripts) keeps resolving its symbols through manager.mjs unchanged.
2
+ // Thin facade over the split session-manager modules in ./manager/.
7
3
  //
8
4
  // Module map:
9
5
  // manager/runtime-liveness.mjs — in-memory stage/heartbeat + accessors
@@ -1,5 +1,4 @@
1
1
  // Thin re-export facade — re-exports all cache symbols from their split modules.
2
- // Existing import paths keep working unchanged.
3
2
  export * from './cache/read-cache.mjs';
4
3
  export * from './cache/scoped-cache.mjs';
5
4
  export * from './cache/prefetch-cache.mjs';
@@ -1,15 +1,5 @@
1
1
  // Async one-shot shell runner.
2
2
  //
3
- // Replaces the legacy spawnSync path in builtin.mjs shell execution. The
4
- // improvements over spawnSync are:
5
- // - native process-tree termination on timeout / abort.
6
- // - automatic spill to $PLUGIN_DATA/shell-output/<taskId>.* once the
7
- // in-memory buffers exceed SHELL_OUTPUT_INLINE_CAP bytes. The caller
8
- // receives an outputFilePath marker the model can FileRead later
9
- // instead of losing the tail past the inline cap.
10
- // - external AbortSignal hookup so a session-scoped abort (ESC, new
11
- // prompt) cancels in-flight bash work without orphaning the child.
12
- //
13
3
  // One run is an explicit state record (lib/shell-run-state.mjs) driven
14
4
  // through three phases: spawn (lib/shell-run-spawn.mjs), settle
15
5
  // (lib/shell-run-settle.mjs) and the auto-background promotion
@@ -1,13 +1,9 @@
1
1
  // Thin facade for the channels worker runtime. The implementation was split
2
2
  // out into ./lib/worker-main.mjs plus focused lib/ modules; this file preserves
3
- // the exact public API and boot side-effects by re-exporting from worker-main.
3
+ // the current worker API and boot side-effects by re-exporting from worker-main.
4
4
  // Kept as the channels worker entrypoint the daemon loads.
5
5
  export {
6
- TOOL_DEFS,
7
6
  handleToolCall,
8
- handleToolCallWithBridgeRetry,
9
- init,
10
- instructions,
11
7
  isChannelBridgeActive,
12
8
  isChannelsDegraded,
13
9
  start,
@@ -114,8 +114,7 @@ function getControlResponsePath(instanceId) {
114
114
  function getStopFlagPath(instanceId) {
115
115
  return join(RUNTIME_ROOT, `stop-${sanitize(instanceId)}.flag`);
116
116
  }
117
- // Pure metadata-advert read: ownership is the OS seat lock, so a leftover
118
- // advert is never evicted here. A transient read during an atomic rename may
117
+ // Pure metadata-advert read. A transient read during an atomic rename may
119
118
  // yield empty/partial content: retry once after 50 ms before reporting the
120
119
  // advert as unreadable.
121
120
  function readActiveInstance() {
@@ -141,8 +140,6 @@ function buildActiveInstanceState(instanceId, meta) {
141
140
  supervisor_started_at: Date.now(),
142
141
  server_started_at: Date.now(),
143
142
  updatedAt: Date.now(),
144
- turnEndFile: getTurnEndPath(instanceId),
145
- statusFile: getStatusPath(instanceId),
146
143
  ...metaFields(meta),
147
144
  };
148
145
  }
@@ -193,26 +190,14 @@ function refreshActiveInstance(instanceId, meta, options) {
193
190
  (curRaw) => {
194
191
  const prevForPreserve = curRaw;
195
192
  const prev = activeInstanceStaleReason(curRaw) ? null : curRaw;
196
- // CAS guard (opt-in via options.onlyIfOwned): heartbeat/refresh ticks
197
- // check ownership OUTSIDE this lock, so a newer session can claim the
198
- // seat between that check and this locked update. Without this re-read,
199
- // the tick would overwrite the newer owner's instanceId (TOCTOU
200
- // re-steal -> ownership ping-pong / double provider connections).
201
- // Returning undefined aborts the update with no write. Explicit claims
202
- // (boot, claimBridgeOwnership) omit the option and stay last-wins.
203
- if (options?.onlyIfOwned && (!prev?.instanceId || prev.instanceId !== instanceId)) {
204
- return undefined;
205
- }
206
- // CAS guard (opt-in via options.onlyIfVacant): auto-start claim-if-vacant.
207
- // Abort the write when a live (non-stale) owner OTHER than us already holds
208
- // the seat — an auto-start must never steal from a live owner. A stale/dead
209
- // prior owner leaves prev=null above, so it does NOT block the claim; an
210
- // absent seat (prev=null) is claimable too.
211
- if (options?.onlyIfVacant && prev?.instanceId && prev.instanceId !== instanceId) {
212
- return undefined;
213
- }
214
193
  // Drop stale fields (pid/startedAt) written by older server versions.
215
- const { pid: _legacyPid, startedAt: _legacyStartedAt, ...prevRest } = prev ?? {};
194
+ const {
195
+ pid: _legacyPid,
196
+ startedAt: _legacyStartedAt,
197
+ turnEndFile: _turnEndFile,
198
+ statusFile: _statusFile,
199
+ ...prevRest
200
+ } = prev ?? {};
216
201
  const identity = buildRuntimeIdentity();
217
202
  // server_started_at tracks the CURRENT server_pid's start time so the
218
203
  // dev-sync barrier can verify the CHILD's freshness (the supervisor's
@@ -82,12 +82,6 @@ function createToolDispatch({ isChannelsDegraded, lifecycle }) {
82
82
  };
83
83
  break;
84
84
  }
85
- case 'rebind_current_transcript': {
86
- // Transcript forwarding is retired with channel messaging: accept
87
- // and no-op so legacy lead-side pushes never surface an error.
88
- result = { content: [{ type: 'text', text: 'transcript rebind retired' }] };
89
- break;
90
- }
91
85
  // memory — handled by memory-service.mjs MCP
92
86
  default:
93
87
  result = {
@@ -105,13 +99,7 @@ function createToolDispatch({ isChannelsDegraded, lifecycle }) {
105
99
  return result;
106
100
  }
107
101
 
108
- // IPC/HTTP wrapper: with channel messaging retired there is no transcript
109
- // pre-flush or tool-log relay — plain dispatch.
110
- async function handleToolCallWithBridgeRetry(toolName, args, signal) {
111
- return handleToolCall(toolName, args, signal);
112
- }
113
-
114
- return { handleToolCall, handleToolCallWithBridgeRetry };
102
+ return { handleToolCall };
115
103
  }
116
104
 
117
105
  export { createToolDispatch };
@@ -14,7 +14,7 @@ export function runWorkerIpc({
14
14
  getProvider: _getProvider,
15
15
  getConfig: _getConfig,
16
16
  handleMemoryCallResponse,
17
- handleToolCallWithBridgeRetry,
17
+ handleToolCall,
18
18
  bootProfile,
19
19
  }) {
20
20
  const sendToParent = (message) =>
@@ -94,7 +94,7 @@ export function runWorkerIpc({
94
94
  _inFlightChannelCalls.set(msg.callId, ac);
95
95
  let result;
96
96
  try {
97
- result = await handleToolCallWithBridgeRetry(msg.name, msg.args || {}, ac.signal);
97
+ result = await handleToolCall(msg.name, msg.args || {}, ac.signal);
98
98
  } finally {
99
99
  _inFlightChannelCalls.delete(msg.callId);
100
100
  }
@@ -4,7 +4,6 @@ import { createRequire } from 'node:module';
4
4
  import { loadConfig, createProvider, DATA_DIR } from './config.mjs';
5
5
  import { resolveVoiceRuntime } from './voice-runtime-fetcher.mjs';
6
6
  import { whisperThreadCount } from './voice-transcription.mjs';
7
- import { TOOL_DEFS } from '../tool-defs.mjs';
8
7
  import { ensureReady, stopVoiceWhisperServer } from './whisper-server.mjs';
9
8
  import { ensurePrivateRuntimeRoot, resolveRuntimeRoot } from '../../shared/runtime-root.mjs';
10
9
  import { Scheduler } from './scheduler.mjs';
@@ -102,7 +101,6 @@ runWorkerBootstrap({
102
101
  pruneStalePluginDataLogSiblings,
103
102
  DEFAULT_STALE_LOG_SIBLING_MAX,
104
103
  });
105
- const INSTRUCTIONS = '';
106
104
 
107
105
  // ── Parent notification helper ───────────────────────────────────────
108
106
  // This worker has no MCP transport of its own. All notifications flow
@@ -129,10 +127,7 @@ let _reloadDebounce = null;
129
127
  const STATUS_FILE = getStatusPath(INSTANCE_ID);
130
128
  const statusState = new JsonStateFile(STATUS_FILE, {});
131
129
  statusState.ensure();
132
- const scheduler = new Scheduler(
133
- config.nonInteractive ?? [],
134
- config.interactive ?? []
135
- );
130
+ const scheduler = new Scheduler(config.nonInteractive ?? [], config.interactive ?? []);
136
131
  // Schedules fire as visible sessions; the session runner belongs to the
137
132
  // session-runtime layer, so this worker entry injects it (lazy, like webhooks).
138
133
  scheduler.setScheduleRunner(async (schedule, opts) => {
@@ -250,19 +245,12 @@ function wireEventQueueHandlers(eventQueue) {
250
245
  // re-read at probe time so a stale-connected flag cannot mask a lost seat.
251
246
  eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
252
247
  }
253
- // Inbound messaging, provider interaction handlers, and channel voice-message
254
- // transcription are DELETED with Discord/Telegram (user decision: PWA replaces
255
- // channels). The headless provider emits no events, so nothing wires here.
256
- // Tool dispatch in worker mode goes through the IPC `call` handler at the
257
- // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
258
- // orphan worker-level MCP Server: the parent (server.mjs) owns the single
259
- // connected transport and routes CallTool through the IPC `call` path.
260
248
  // ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
261
249
  // Runtime lifecycle dispatch only (activate_channel_bridge / reload_config):
262
250
  // threaded as a lifecycle bag of lazy getters so the module reads live
263
251
  // file-level references at call time. Used by the HTTP MCP CallTool path and
264
252
  // the worker IPC `call` handler at the bottom of this file.
265
- const { handleToolCall, handleToolCallWithBridgeRetry } = createToolDispatch({
253
+ const { handleToolCall } = createToolDispatch({
266
254
  isChannelsDegraded,
267
255
  lifecycle: {
268
256
  getChannelBridgeActive: () => channelBridgeActive,
@@ -277,14 +265,6 @@ const { handleToolCall, handleToolCallWithBridgeRetry } = createToolDispatch({
277
265
  reloadRuntimeConfig,
278
266
  },
279
267
  });
280
- async function init(_sharedMcp) {
281
- // _sharedMcp is no longer used. Notifications now flow via IPC to the parent
282
- // (sendNotifyToParent above). The parameter is retained for backward
283
- // compatibility with any caller that still passes a Server reference.
284
- scheduler.setInjectHandler((channelId, name, content, options) => {
285
- injectAndRecord(channelId, name, content, options);
286
- });
287
- }
288
268
  function ensureConfigWatcher() {
289
269
  if (_configWatcher) return;
290
270
  try {
@@ -374,19 +354,9 @@ if (_isWorkerMode && process.send && process.env.MIXDOG_DAEMON_HOST !== '1') {
374
354
  getProvider: () => provider,
375
355
  getConfig: () => config,
376
356
  handleMemoryCallResponse,
377
- handleToolCallWithBridgeRetry,
357
+ handleToolCall,
378
358
  bootProfile,
379
359
  });
380
360
  }
381
361
 
382
- export {
383
- TOOL_DEFS,
384
- handleToolCall,
385
- handleToolCallWithBridgeRetry,
386
- init,
387
- INSTRUCTIONS as instructions,
388
- isChannelBridgeActive,
389
- isChannelsDegraded,
390
- start,
391
- stop,
392
- };
362
+ export { handleToolCall, isChannelBridgeActive, isChannelsDegraded, start, stop };
@@ -0,0 +1 @@
1
+ export function compareSemver(a: string, b: string): number;
@@ -0,0 +1,57 @@
1
+ // SemVer 2.0.0 precedence (§11), dependency-free so both Node and the desktop
2
+ // renderer can use it. A leading `v` is tolerated, build metadata is ignored,
3
+ // and a missing or non-numeric core part reads as 0.
4
+
5
+ /** Parse a semver-ish string into { parts:[maj,min,patch], prerelease:[ids] }. */
6
+ function parseSemver(value) {
7
+ const text = String(value || '')
8
+ .trim()
9
+ .replace(/^v/i, '')
10
+ .split('+')[0];
11
+ const dash = text.indexOf('-');
12
+ const core = dash === -1 ? text : text.slice(0, dash);
13
+ const parts = core.split('.').map((n) => {
14
+ const num = Number.parseInt(n, 10);
15
+ return Number.isFinite(num) ? num : 0;
16
+ });
17
+ while (parts.length < 3) parts.push(0);
18
+ return { parts, prerelease: dash === -1 ? [] : text.slice(dash + 1).split('.') };
19
+ }
20
+
21
+ const NUMERIC = /^\d+$/;
22
+
23
+ // Numeric identifiers compare numerically and rank below alphanumeric ones,
24
+ // which compare in ASCII order.
25
+ function compareIdentifiers(a, b) {
26
+ const aNumeric = NUMERIC.test(a);
27
+ const bNumeric = NUMERIC.test(b);
28
+ if (aNumeric && bNumeric) {
29
+ const diff = BigInt(a) - BigInt(b);
30
+ return diff === 0n ? 0 : diff > 0n ? 1 : -1;
31
+ }
32
+ if (aNumeric) return -1;
33
+ if (bNumeric) return 1;
34
+ return a === b ? 0 : a < b ? -1 : 1;
35
+ }
36
+
37
+ /**
38
+ * Compare two semver strings: positive if `a` > `b`, negative if `a` < `b`,
39
+ * 0 if they have equal precedence. A prerelease ranks below its release, and
40
+ * of two prereleases sharing every identifier the shorter one is lower.
41
+ */
42
+ export function compareSemver(a, b) {
43
+ const pa = parseSemver(a);
44
+ const pb = parseSemver(b);
45
+ for (let i = 0; i < 3; i++) {
46
+ const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0);
47
+ if (diff !== 0) return diff > 0 ? 1 : -1;
48
+ }
49
+ const aPre = pa.prerelease.length;
50
+ const bPre = pb.prerelease.length;
51
+ if (!aPre || !bPre) return aPre === bPre ? 0 : aPre ? -1 : 1;
52
+ for (let i = 0; i < Math.min(aPre, bPre); i++) {
53
+ const diff = compareIdentifiers(pa.prerelease[i], pb.prerelease[i]);
54
+ if (diff !== 0) return diff;
55
+ }
56
+ return Math.sign(aPre - bPre);
57
+ }
@@ -1,6 +1,6 @@
1
1
  // ── Facade: tool-surface primitives + result summaries are re-exported from
2
- // their cohesive modules so every existing importer resolves unchanged. This
3
- // file keeps the display-name / arg-summary and aggregate-card clusters.
2
+ // their cohesive modules. This file keeps the display-name / arg-summary and
3
+ // aggregate-card clusters.
4
4
  import {
5
5
  DEFAULT_SUMMARY_MAX,
6
6
  AGENT_SURFACE_BRIEF_MAX,
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url';
15
15
  import { writeJsonAtomicSync } from './atomic-file.mjs';
16
16
  import { readJsonSafe } from './json-file.mjs';
17
17
  import { resolvePluginData } from './plugin-paths.mjs';
18
+ import { compareSemver } from './semver.mjs';
18
19
 
19
20
  const PACKAGE_NAME = 'mixdog';
20
21
  const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
@@ -84,37 +85,7 @@ function writeCache(dataDir, payload) {
84
85
  }
85
86
  }
86
87
 
87
- /** Parse a semver-ish string into { parts:[maj,min,patch], prerelease }. */
88
- function parseSemver(value) {
89
- const text = String(value || '')
90
- .trim()
91
- .replace(/^v/i, '');
92
- const [core, ...preParts] = text.split('-');
93
- const parts = core.split('.').map((n) => {
94
- const num = Number.parseInt(n, 10);
95
- return Number.isFinite(num) ? num : 0;
96
- });
97
- while (parts.length < 3) parts.push(0);
98
- return { parts, prerelease: preParts.join('-') };
99
- }
100
-
101
- /**
102
- * Compare two semver strings. Returns positive if `a` > `b`, negative if
103
- * `a` < `b`, 0 if equal. A version with a prerelease tag is considered
104
- * lower than the same core version without one (standard semver rule).
105
- */
106
- export function compareSemver(a, b) {
107
- const pa = parseSemver(a);
108
- const pb = parseSemver(b);
109
- for (let i = 0; i < 3; i++) {
110
- const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0);
111
- if (diff !== 0) return diff;
112
- }
113
- if (pa.prerelease && !pb.prerelease) return -1;
114
- if (!pa.prerelease && pb.prerelease) return 1;
115
- if (pa.prerelease !== pb.prerelease) return pa.prerelease < pb.prerelease ? -1 : 1;
116
- return 0;
117
- }
88
+ export { compareSemver };
118
89
 
119
90
  export function isNewerVersion(latest, current) {
120
91
  if (!latest || !current) return false;
@@ -74,7 +74,6 @@ export async function loadModules(boot) {
74
74
  statusRoutes,
75
75
  webSearchToolDefs,
76
76
  memoryToolDefs,
77
- channelToolDefs,
78
77
  codeGraphToolDefs,
79
78
  } = await loadRuntimeModules();
80
79
  const {
@@ -103,7 +102,6 @@ export async function loadModules(boot) {
103
102
  statusRoutes,
104
103
  webSearchToolDefs,
105
104
  memoryToolDefs,
106
- channelToolDefs,
107
105
  codeGraphToolDefs,
108
106
  recapEnabledFn,
109
107
  memoryToolsEnabledFn,
@@ -27,7 +27,10 @@ import { createRoutedAgentTool } from '../agent-tool-routing.mjs';
27
27
  import { createGoalRuntime } from '../../runtime/agent/orchestrator/runtime-core/goal-runtime.mjs';
28
28
  import { collectStandaloneToolDefs } from '../../runtime/agent/orchestrator/runtime-core/tool-defs.mjs';
29
29
  import { createToolSurface } from '../tool-surface.mjs';
30
- import { contextStatusForSession, createContextStatus } from '../../runtime/agent/orchestrator/runtime-core/context-status.mjs';
30
+ import {
31
+ contextStatusForSession,
32
+ createContextStatus,
33
+ } from '../../runtime/agent/orchestrator/runtime-core/context-status.mjs';
31
34
  import { createInternalToolExecutor } from '../internal-tool-executor.mjs';
32
35
  import { STANDALONE_ROOT, STANDALONE_DATA_DIR } from '../runtime-paths.mjs';
33
36
  import { dataDirOf, workflowHelpers } from './shared.mjs';
@@ -141,12 +144,11 @@ function wireChannels(boot) {
141
144
  // Tool definitions, then the lead tool surface (workflow-gated agent tool,
142
145
  // pre-session preview, deferred replay) from tool-surface.mjs.
143
146
  function wireToolSurface(boot) {
144
- const { rt, cfgMod, mgr, goalRuntime, agentTool, channels, featureDisallowedTools } = boot;
147
+ const { rt, cfgMod, mgr, goalRuntime, agentTool, featureDisallowedTools } = boot;
145
148
  const toolsStartedAt = performance.now();
146
149
  const { standaloneTools, internalToolDefs, agentToolNames } = collectStandaloneToolDefs({
147
150
  webSearchToolDefs: boot.webSearchToolDefs,
148
151
  memoryToolDefs: boot.memoryToolDefs,
149
- channelToolDefs: boot.channelToolDefs,
150
152
  codeGraphToolDefs: boot.codeGraphToolDefs,
151
153
  browserToolDefs: BROWSER_BRIDGE_TOOL_DEFS,
152
154
  computerToolDefs: COMPUTER_BRIDGE_TOOL_DEFS,
@@ -157,7 +159,6 @@ function wireToolSurface(boot) {
157
159
  setupToolDefs: SETUP_TOOL_DEFS,
158
160
  goalTools: goalRuntime.tools,
159
161
  agentTools: agentTool.tools,
160
- isChannelTool: (name) => channels.isChannelTool(name),
161
162
  skillToolEnabled: !envFlag('MIXDOG_DISABLE_SKILLS'),
162
163
  });
163
164
  bootProfile('tools:ready', { ms: (performance.now() - toolsStartedAt).toFixed(1), count: standaloneTools.length });
@@ -113,10 +113,14 @@ export function createMcpResourceApi({ deps, sync, decorate }) {
113
113
  },
114
114
  async saveMcpServer(input = {}) {
115
115
  const servers = mcpServersOf(getConfig());
116
- const originalName = clean(input.originalName) || clean(input.name);
117
- if (originalName && !Object.hasOwn(servers, originalName)) {
118
- throw new Error(`MCP server not configured: ${originalName}`);
116
+ // An explicit originalName must exist; a bare name edits a matching
117
+ // server and otherwise creates a new one.
118
+ const explicitOriginal = clean(input.originalName);
119
+ if (explicitOriginal && !Object.hasOwn(servers, explicitOriginal)) {
120
+ throw new Error(`MCP server not configured: ${explicitOriginal}`);
119
121
  }
122
+ const bareName = clean(input.name);
123
+ const originalName = explicitOriginal || (Object.hasOwn(servers, bareName) ? bareName : '');
120
124
  const existing = originalName ? servers[originalName] : {};
121
125
  const normalizedInput = normalizeMcpServerInput(mergedServerInput(input, existing, originalName));
122
126
  const name = originalName && clean(input.name) === originalName ? originalName : normalizedInput.name;
@@ -10,7 +10,6 @@ import {
10
10
  RUNTIME,
11
11
  WEB_SEARCH_TOOL_DEFS,
12
12
  MEMORY_TOOL_DEFS,
13
- CHANNEL_TOOL_DEFS,
14
13
  CODE_GRAPH_TOOL_DEFS,
15
14
  STATUSLINE_SESSION_ROUTES,
16
15
  STANDALONE_ROOT,
@@ -49,7 +48,6 @@ export async function loadRuntimeModules() {
49
48
  statusRoutes,
50
49
  webSearchToolDefs,
51
50
  memoryToolDefs,
52
- channelToolDefs,
53
51
  codeGraphToolDefs,
54
52
  ] = await Promise.all([
55
53
  profiledImport('config', `${RUNTIME}/config.mjs`),
@@ -62,7 +60,6 @@ export async function loadRuntimeModules() {
62
60
  profiledImport('status-routes', STATUSLINE_SESSION_ROUTES, { optional: true }),
63
61
  profiledImport('web-search-tool-defs', WEB_SEARCH_TOOL_DEFS, { optional: true }),
64
62
  profiledImport('memory-tool-defs', MEMORY_TOOL_DEFS, { optional: true }),
65
- profiledImport('channel-tool-defs', CHANNEL_TOOL_DEFS, { optional: true }),
66
63
  profiledImport('code-graph-tool-defs', CODE_GRAPH_TOOL_DEFS, { optional: true }),
67
64
  ]);
68
65
  bootProfile('imports:ready', { ms: (performance.now() - startedAt).toFixed(1) });
@@ -88,7 +85,6 @@ export async function loadRuntimeModules() {
88
85
  statusRoutes,
89
86
  webSearchToolDefs,
90
87
  memoryToolDefs,
91
- channelToolDefs,
92
88
  codeGraphToolDefs,
93
89
  };
94
90
  }
@@ -6,7 +6,6 @@ export const RUNTIME = '../runtime/agent/orchestrator';
6
6
  export const WEB_SEARCH_RUNTIME = '../runtime/web-search/index.mjs';
7
7
  export const WEB_SEARCH_TOOL_DEFS = '../runtime/web-search/tool-defs.mjs';
8
8
  export const MEMORY_TOOL_DEFS = '../runtime/memory/tool-defs.mjs';
9
- export const CHANNEL_TOOL_DEFS = '../runtime/channels/tool-defs.mjs';
10
9
  export const CODE_GRAPH_TOOL_DEFS = '../runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs';
11
10
  export const CODE_GRAPH_RUNTIME = '../runtime/agent/orchestrator/tools/code-graph.mjs';
12
11
  export const STATUSLINE_SESSION_ROUTES = '../vendor/statusline/src/gateway/session-routes.mjs';
@@ -18,7 +18,7 @@ import { daemonEnv, spawnDaemonCandidate } from './channel-worker/daemon-spawn.m
18
18
 
19
19
  export { pruneStaleChannelClientHeartbeats };
20
20
 
21
- const CHANNEL_TOOLS = new Set(['activate_channel_bridge', 'reload_config', 'rebind_current_transcript']);
21
+ const CHANNEL_TOOLS = new Set(['activate_channel_bridge', 'reload_config']);
22
22
  const CALL_RETRIES = 3;
23
23
 
24
24
  function logLine(path, line) {
@@ -1,6 +1,13 @@
1
1
  // Onboarding, the user profile (/profile) and the disabled-skill list. Methods
2
2
  // reference `this.*` so cross-member calls resolve against the facade object
3
3
  // these are spread into.
4
+ import {
5
+ mergeStarterGuide,
6
+ requiredStarterGuideState,
7
+ storedStarterGuide,
8
+ withStarterGuideOnCompletion,
9
+ } from './starter-guide-state.mjs';
10
+
4
11
  export function createProfileSettings({
5
12
  getConfig,
6
13
  adoptConfig,
@@ -19,21 +26,39 @@ export function createProfileSettings({
19
26
  version: nextConfig?.onboarding?.version || 0,
20
27
  default: nextConfig?.default || null,
21
28
  workflowRoutes: summarizeWorkflowRoutes(nextConfig),
29
+ starterGuide: storedStarterGuide(nextConfig?.onboarding?.starterGuide),
22
30
  };
23
31
  },
32
+ // Persist the desktop "Getting started" guide state beside the onboarding
33
+ // flags so it survives a lost renderer localStorage. Malformed input is
34
+ // rejected; a valid save merges forward, so a stale one cannot reopen a
35
+ // closed guide or forget a visited step.
36
+ saveStarterGuideState(state) {
37
+ const next = requiredStarterGuideState(state);
38
+ const config = getConfig();
39
+ const onboarding = config.onboarding || {};
40
+ saveConfigAndAdopt({
41
+ ...config,
42
+ onboarding: {
43
+ ...onboarding,
44
+ starterGuide: mergeStarterGuide(storedStarterGuide(onboarding.starterGuide), next),
45
+ },
46
+ });
47
+ return this.getOnboardingStatus();
48
+ },
24
49
  // Mark onboarding as done WITHOUT touching routes/agents/provider. Used by
25
50
  // the TUI "skip" (Esc) path so the wizard doesn't reappear next launch,
26
51
  // while leaving any existing config routes untouched.
27
52
  skipOnboarding() {
28
53
  const config = getConfig();
29
54
  const nextConfig = { ...config };
30
- nextConfig.onboarding = {
55
+ nextConfig.onboarding = withStarterGuideOnCompletion(config.onboarding, {
31
56
  ...(nextConfig.onboarding || {}),
32
57
  completed: true,
33
58
  version: ONBOARDING_VERSION,
34
59
  completedAt: new Date().toISOString(),
35
60
  skipped: true,
36
- };
61
+ });
37
62
  saveConfigAndAdopt(nextConfig);
38
63
  return this.getOnboardingStatus();
39
64
  },
@@ -0,0 +1,62 @@
1
+ // The desktop "Getting started" guide state, kept in the config beside the
2
+ // onboarding flags (`onboarding.starterGuide`) so it outlives the renderer's
3
+ // localStorage. The desktop IPC gate (ipc-validation.ts) mirrors the schema.
4
+ export const STARTER_GUIDE_STEPS = Object.freeze(['provider', 'project', 'workflow', 'extensions', 'remote']);
5
+ const STARTER_GUIDE_KEYS = new Set(['closed', 'welcomed', 'visited']);
6
+
7
+ const plainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
8
+
9
+ /** The guide state of a user who has not answered it yet. */
10
+ export function freshStarterGuide() {
11
+ return { closed: false, welcomed: false, visited: [] };
12
+ }
13
+
14
+ /** The stored copy, read tolerantly; null when there is none. */
15
+ export function storedStarterGuide(value) {
16
+ if (!plainObject(value)) return null;
17
+ const visited = Array.isArray(value.visited) ? value.visited : [];
18
+ return {
19
+ closed: value.closed === true,
20
+ welcomed: value.welcomed === true,
21
+ visited: STARTER_GUIDE_STEPS.filter((step) => visited.includes(step)),
22
+ };
23
+ }
24
+
25
+ /** A save request, read strictly: anything off-schema is rejected, never coerced. */
26
+ export function requiredStarterGuideState(value) {
27
+ if (!plainObject(value)) throw new TypeError('starter guide state must be an object.');
28
+ for (const key of Object.keys(value)) {
29
+ if (!STARTER_GUIDE_KEYS.has(key)) throw new TypeError(`starter guide state has an unknown field: ${key}.`);
30
+ }
31
+ if (typeof value.closed !== 'boolean' || typeof value.welcomed !== 'boolean') {
32
+ throw new TypeError('starter guide closed and welcomed must be booleans.');
33
+ }
34
+ if (
35
+ !Array.isArray(value.visited) ||
36
+ value.visited.length > STARTER_GUIDE_STEPS.length * 2 ||
37
+ value.visited.some((step) => !STARTER_GUIDE_STEPS.includes(step))
38
+ ) {
39
+ throw new TypeError('starter guide visited must list known steps.');
40
+ }
41
+ return storedStarterGuide(value);
42
+ }
43
+
44
+ /** Every field only moves forward: a stale save can add, never undo. */
45
+ export function mergeStarterGuide(stored, next) {
46
+ const base = stored || freshStarterGuide();
47
+ return {
48
+ closed: base.closed || next.closed,
49
+ welcomed: base.welcomed || next.welcomed,
50
+ visited: STARTER_GUIDE_STEPS.filter((step) => base.visited.includes(step) || next.visited.includes(step)),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The onboarding section after a completion: a first completion starts the
56
+ * guide fresh, so an absent copy afterwards means a profile onboarded before
57
+ * the guide was persisted here.
58
+ */
59
+ export function withStarterGuideOnCompletion(previous, onboarding) {
60
+ if (previous?.completed === true || storedStarterGuide(previous?.starterGuide)) return onboarding;
61
+ return { ...onboarding, starterGuide: freshStarterGuide() };
62
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from '../../runtime/agent/orchestrator/runtime-core/workflow.mjs';
13
13
  import { ONBOARDING_VERSION } from '../quick-web-search-models.mjs';
14
14
  import { canonicalizeAgentRouteStorage } from '../../runtime/shared/agent-route-config.mjs';
15
+ import { withStarterGuideOnCompletion } from '../starter-guide-state.mjs';
15
16
  import { resolveDataDir } from './shared.mjs';
16
17
 
17
18
  // Onboarding slot picks: lead → default preset, agent → worker route,
@@ -91,12 +92,12 @@ export function createOnboardingApi(deps) {
91
92
  ]);
92
93
  nextConfig.agents = applyAgentRouteInput(agentInput, draft.agentRoutes, configurableIds);
93
94
  }
94
- nextConfig.onboarding = {
95
+ nextConfig.onboarding = withStarterGuideOnCompletion(config.onboarding, {
95
96
  ...(nextConfig.onboarding || {}),
96
97
  completed: true,
97
98
  version: ONBOARDING_VERSION,
98
99
  completedAt: new Date().toISOString(),
99
- };
100
+ });
100
101
  if (payload.webSearchRoute) {
101
102
  const webSearchToSave = webSearchRouteToSave(payload.webSearchRoute);
102
103
  if (webSearchToSave) nextConfig.webSearchRoute = webSearchToSave;
@@ -2,8 +2,7 @@ import { readFileSync, rmSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
3
 
4
4
  export const ACTIVATE_TOOL = 'activate_channel_bridge';
5
- export const REBIND_TOOL = 'rebind_current_transcript';
6
- export const BINDING_TOOLS = new Set([ACTIVATE_TOOL, REBIND_TOOL]);
5
+ export const BINDING_TOOLS = new Set([ACTIVATE_TOOL]);
7
6
 
8
7
  export function remoteSessionIdFromBinding(name, args) {
9
8
  if (!BINDING_TOOLS.has(name) || !args || typeof args !== 'object') return null;
@@ -2,7 +2,7 @@
2
2
  * call-dispatch.mjs — one /call of the channel transport: the fair lanes
3
3
  * (ordinary vs. control/binding), the callId idempotency cache, and the
4
4
  * binding-aware run that moves the UI control pointer / durable pin around a
5
- * manual Remote ON/OFF or transcript rebind and rolls it back on failure.
5
+ * manual Remote ON/OFF and rolls it back on failure.
6
6
  *
7
7
  * Shared transport state read/written here: clients, pointerToken,
8
8
  * remoteAcquired, stickyRemoteFrame, pinnedSessionId, closed.
@@ -11,7 +11,7 @@ import { createFairCallScheduler } from '../fair-call-scheduler.mjs';
11
11
  import { callSignature, callIdConflict, clientCallOwner } from '../rpc-call-identity.mjs';
12
12
  import { isPidAlive } from '../../runtime/shared/pid-liveness.mjs';
13
13
  import { positiveInt } from '../../runtime/shared/numbers.mjs';
14
- import { ACTIVATE_TOOL, REBIND_TOOL, BINDING_TOOLS, remoteSessionIdFromBinding } from '../channel-binding.mjs';
14
+ import { ACTIVATE_TOOL, BINDING_TOOLS, remoteSessionIdFromBinding } from '../channel-binding.mjs';
15
15
 
16
16
  const CALL_CACHE_TTL_MS = 60_000;
17
17
 
@@ -52,10 +52,10 @@ export function createCallDispatch({
52
52
  const activationCall = name === ACTIVATE_TOOL;
53
53
  const activating = activationCall && args.active === true;
54
54
  const deactivating = activationCall && args.active === false;
55
- const rebindCall = name === REBIND_TOOL;
56
55
  const bindingSessionId = remoteSessionIdFromBinding(name, args);
57
56
  // Auto/implicit activation is retired. A valid manual ON always carries
58
- // its target session and explicitly overrides the old one.
57
+ // its target session and explicitly overrides the old one. Legacy automatic
58
+ // requests remain non-authoritative even when they include a session id.
59
59
  if (activating && (args.claimIfVacant === true || !bindingSessionId)) {
60
60
  return {
61
61
  content: [{ type: 'text', text: 'channel bridge claim skipped: manual ON required' }],
@@ -71,12 +71,6 @@ export function createCallDispatch({
71
71
  releaseSkipped: true,
72
72
  };
73
73
  }
74
- if (rebindCall && !pinnedSessionMatch) {
75
- return {
76
- content: [{ type: 'text', text: 'transcript rebind skipped: session is not pinned' }],
77
- rebindSkipped: true,
78
- };
79
- }
80
74
 
81
75
  const previousPointerToken = state.pointerToken;
82
76
  const previousRemoteSessionId = c.remoteSessionId || null;
@@ -46,14 +46,13 @@ export function createChannelsRuntimeLoader({ log, onLoaded = () => {}, setOwner
46
46
 
47
47
  async function handleCall(name, args, ctx) {
48
48
  const module = await ensure();
49
- // Accepted owner controls refresh active-instance context. The transport
50
- // admits rebind only for the current manual owner.
49
+ // Accepted owner controls refresh active-instance context.
51
50
  if (ctx && BINDING_TOOLS.has(name)) {
52
51
  try {
53
52
  setOwnerContext({ leadPid: ctx.leadPid, cwd: ctx.cwd });
54
53
  } catch {}
55
54
  }
56
- return module.handleToolCallWithBridgeRetry(name, args || {});
55
+ return module.handleToolCall(name, args || {});
57
56
  }
58
57
 
59
58
  return { ensure, start, handleCall };
@@ -37,90 +37,14 @@ import { tuiDebug, nextId, createTranscriptSpillBuffer } from '../tui/session/tr
37
37
 
38
38
  const SESSION_RUNTIME_MODULE = '../mixdog-session-runtime.mjs';
39
39
 
40
- // The runtime graph is imported lazily, but that import (measured ~250ms) used
41
- // to land inside the FIRST session runtime creation, which desktop performs while it
42
- // holds its transition lock — so the user's first navigation paid for it. Hosts
43
- // can start it during their own idle startup instead; the promise is shared, so
44
- // the create path either awaits an in-flight preload or does the import itself.
40
+ // Shared across create calls so concurrent session startups import the
41
+ // runtime graph once.
45
42
  let sessionRuntimeModulePromise = null;
46
43
  function importSessionRuntimeModule() {
47
44
  sessionRuntimeModulePromise ??= import(SESSION_RUNTIME_MODULE);
48
45
  return sessionRuntimeModulePromise;
49
46
  }
50
47
 
51
- export function preloadSessionRuntimeModule() {
52
- return importSessionRuntimeModule().catch(() => {
53
- // A real load failure surfaces on the authoritative create path.
54
- sessionRuntimeModulePromise = null;
55
- });
56
- }
57
-
58
- let agentLoopPrewarmPromise = null;
59
- export function preloadAgentLoopRuntime() {
60
- agentLoopPrewarmPromise ??= import('../runtime/agent/orchestrator/session/manager/runtime-loaders.mjs').then(
61
- (module) => module.prewarmAgentLoop()
62
- );
63
- void agentLoopPrewarmPromise.catch(() => {
64
- // Reservation-time prewarm retries through the same shared loader.
65
- agentLoopPrewarmPromise = null;
66
- });
67
- return agentLoopPrewarmPromise;
68
- }
69
-
70
- // Memory-runtime attach (PG proxy + embed warmup) measured ~6.5s cold and is
71
- // the largest first-turn blocker: loadCoreMemoryContext races it with a 2s
72
- // cap, but a cold attach stalls the event loop hard enough that even that
73
- // timer fires seconds late (probe: core-memory settle at ~6.0s). Warm the
74
- // EXACT shared proxy instance the session create path resolves —
75
- // getStandaloneMemoryRuntime caches by entry+dataDir, so the first turn finds
76
- // it initialized. Honors the same MIXDOG_BOOT_CORE_MEMORY opt-out.
77
- let memoryRuntimePrewarmPromise = null;
78
- export function preloadMemoryRuntime() {
79
- const bootFlag = String(process.env.MIXDOG_BOOT_CORE_MEMORY ?? '')
80
- .trim()
81
- .toLowerCase();
82
- if (bootFlag === '0' || bootFlag === 'false' || bootFlag === 'no' || bootFlag === 'off') {
83
- return Promise.resolve();
84
- }
85
- memoryRuntimePrewarmPromise ??= (async () => {
86
- const [{ getStandaloneMemoryRuntime }, { getPluginData }, { fileURLToPath }] = await Promise.all([
87
- import('../session-runtime/services/memory-runtime-proxy.mjs'),
88
- import('../runtime/agent/orchestrator/config.mjs'),
89
- import('node:url'),
90
- ]);
91
- const runtime = getStandaloneMemoryRuntime({
92
- entry: fileURLToPath(new URL('../runtime/memory/index.mjs', import.meta.url)),
93
- dataDir: process.env.MIXDOG_DATA_DIR || getPluginData?.() || undefined,
94
- });
95
- await runtime.init();
96
- })();
97
- void memoryRuntimePrewarmPromise.catch(() => {
98
- // Opportunistic: loadCoreMemoryContext retries through the same proxy.
99
- memoryRuntimePrewarmPromise = null;
100
- });
101
- return memoryRuntimePrewarmPromise;
102
- }
103
-
104
- // Windows keychain reads go through a DPAPI PowerShell host whose cold start
105
- // dominates a packaged boot (measured ~1.5s). The runtime batches every secret
106
- // into one call, but it only starts that batch when the runtime is created —
107
- // late enough that the first context switch waits on it. Hosts can start the
108
- // same batch while their window is still coming up.
109
- let keychainPrewarmPromise = null;
110
- export function preloadKeychainSecrets() {
111
- keychainPrewarmPromise ??= (async () => {
112
- try {
113
- const { createRequire } = await import('node:module');
114
- const require = createRequire(import.meta.url);
115
- const keychain = require('../lib/keychain-cjs.cjs');
116
- await keychain.prewarmSecrets?.();
117
- } catch {
118
- // Prewarm is opportunistic; the runtime still warms on its own path.
119
- }
120
- })();
121
- return keychainPrewarmPromise;
122
- }
123
-
124
48
  const TOOL_APPROVAL_TIMEOUT_MS = (() => {
125
49
  const value = Number(process.env.MIXDOG_TOOL_APPROVAL_TIMEOUT_MS);
126
50
  return Number.isFinite(value) && value > 0 ? Math.max(1000, Math.round(value)) : 120_000;
@@ -189,6 +189,7 @@ export const SESSION_CONFIGURE_ACTIONS = Object.freeze([
189
189
  'setWebhookEnabled',
190
190
  'setWorkflow',
191
191
  'setOrchestrationMode',
192
+ 'saveStarterGuideState',
192
193
  'skipOnboarding',
193
194
  'startMediaJob',
194
195
  'switchContext',
@@ -14,15 +14,19 @@ export function createSessionExtensionsApi(bag) {
14
14
  resetStatsAndSyncContext,
15
15
  routeState,
16
16
  });
17
- // One locked runtime mutation, then republish and announce it.
17
+ // One locked runtime mutation, then republish and announce it. A refused
18
+ // change throws so a caller never reports a skipped write as saved.
18
19
  const lockedChange = (call, notice) =>
19
- withCommandLock(async (...args) => {
20
- const result = await call(...args);
21
- refreshRouteStats();
22
- const [text, level = 'info'] = notice(result, ...args);
23
- pushNotice(text, level);
24
- return result;
25
- });
20
+ withCommandLock(
21
+ async (...args) => {
22
+ const result = await call(...args);
23
+ refreshRouteStats();
24
+ const [text, level = 'info'] = notice(result, ...args);
25
+ pushNotice(text, level);
26
+ return result;
27
+ },
28
+ { busyError: 'Another command is still running; try again when it finishes.' }
29
+ );
26
30
  const pluginLabel = (result, plugin) => result?.plugin?.title || result?.plugin?.name || plugin?.name || plugin;
27
31
 
28
32
  return {
@@ -68,6 +68,7 @@ export function createSessionIntegrationsApi(bag, { oauthFlows }) {
68
68
  // Completed-marking only; no route/agent/provider writes.
69
69
  return runtime.skipOnboarding?.() || null;
70
70
  },
71
+ saveStarterGuideState: (state) => runtime.saveStarterGuideState?.(state) || null,
71
72
  completeOnboarding: withCommandLock(async (payload = {}) => {
72
73
  const result = await runtime.completeOnboarding?.(payload);
73
74
  refreshRouteStats();
@@ -5,15 +5,19 @@
5
5
  */
6
6
  export function createApiHelpers({ getState, set, resetStatsAndSyncContext, routeState, syncContextStats }) {
7
7
  /**
8
- * Wrap a runtime call in the command lock: refused (→ `busyResult`) while
9
- * another command holds commandBusy, otherwise held for the call's duration.
10
- * `onRelease` runs after the lock drops (RPC replies read the published
11
- * snapshot, not the mutable draft, so some callers flush there).
8
+ * Wrap a runtime call in the command lock: refused (→ `busyResult`, or a
9
+ * thrown `busyError` for callers that must not read a refusal as success)
10
+ * while another command holds commandBusy, otherwise held for the call's
11
+ * duration. `onRelease` runs after the lock drops (RPC replies read the
12
+ * published snapshot, not the mutable draft, so some callers flush there).
12
13
  */
13
14
  const withCommandLock =
14
- (run, { busyResult = null, onRelease = null } = {}) =>
15
+ (run, { busyResult = null, busyError = '', onRelease = null } = {}) =>
15
16
  async (...args) => {
16
- if (getState().commandBusy) return busyResult;
17
+ if (getState().commandBusy) {
18
+ if (busyError) throw new Error(busyError);
19
+ return busyResult;
20
+ }
17
21
  set({ commandBusy: true });
18
22
  try {
19
23
  return await run(...args);
@@ -1,17 +0,0 @@
1
- export const TOOL_DEFS = [
2
- // reply/fetch model-facing tools removed 2026-08: the output forwarder
3
- // delivers session output to the channel and the inbound bridge injects
4
- // incoming messages, so the model never needed to call them directly.
5
- // memory and recall_memory tools are now provided by memory-service.mjs via MCP
6
- // react/edit_message/download_attachment tools removed (no remaining
7
- // callers); provider editMessage/downloadAttachment/react methods stay for
8
- // internal use.
9
- // schedule_status/trigger_schedule/schedule_control tools removed (no
10
- // remaining callers). activate_channel_bridge/reload_config are NOT model-
11
- // facing tools anymore (no TOOL_DEFS entry) but the underlying
12
- // channels.execute('activate_channel_bridge'|'reload_config', ...) dispatch
13
- // stays alive in index.mjs/channel-worker.mjs because
14
- // mixdog-session-runtime.mjs calls them directly as internal Lead-only
15
- // runtime plumbing (bridge-claim on start, config hot-reload) — see
16
- // reloadChannelsSoon() and the remote-start bridge claim.
17
- ];