mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -10,11 +10,18 @@ import keychain from '../lib/keychain-cjs.cjs';
10
10
  import './hitch-profile.mjs';
11
11
  import { ensureStandaloneEnvironment } from '../standalone/seeds.mjs';
12
12
  import { createStandaloneAgent } from '../standalone/agent-tool.mjs';
13
+ import {
14
+ executeRemoteAgentControl,
15
+ remoteAgentControlEnabled,
16
+ } from '../standalone/session-runtime-agent-control-client.mjs';
13
17
  import { isAgentOwner } from '../runtime/agent/orchestrator/agent-owner.mjs';
14
18
  import { createStandaloneChannelWorker } from '../standalone/channel-worker.mjs';
15
19
  import { createStandaloneHookBus } from '../standalone/hook-bus.mjs';
16
20
  import { getStandaloneMemoryRuntime } from '../standalone/memory-runtime-proxy.mjs';
17
- import { writeLastSessionCwd } from '../runtime/shared/user-cwd.mjs';
21
+ import {
22
+ updateCurrentCwdOverride,
23
+ writeLastSessionCwd,
24
+ } from '../runtime/shared/user-cwd.mjs';
18
25
  import { cancelBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
19
26
  import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
20
27
  import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
@@ -149,7 +156,6 @@ import {
149
156
  } from './output-styles.mjs';
150
157
  import { readJsonSafe, readTextSafe } from './fs-utils.mjs';
151
158
  import {
152
- readProjectMcpServers,
153
159
  countSkillFiles,
154
160
  mcpScriptForPlugin,
155
161
  normalizePluginMcpServerConfig,
@@ -187,7 +193,6 @@ import {
187
193
  estimateToolSchemaBreakdown,
188
194
  measuredToolUsage,
189
195
  parseToolSelection,
190
- parseToolSearchQuerySelection,
191
196
  sortedCatalogByMeasuredUsage,
192
197
  filterDisallowedTools,
193
198
  sortedNamesByMeasuredUsage,
@@ -247,6 +252,7 @@ import { createToolSurface } from './tool-surface.mjs';
247
252
  import { createToolPolicyRefresh } from './tool-policy-refresh.mjs';
248
253
  import { readRuntimeTunables } from './runtime-tunables.mjs';
249
254
  import { createSessionTurnApi } from './session-turn-api.mjs';
255
+ import { createGoalRuntime } from './goal-runtime.mjs';
250
256
  import { providerInitCacheKey } from './provider-init-key.mjs';
251
257
  import { createRoutePreparationGate } from './route-preparation.mjs';
252
258
  import {
@@ -262,6 +268,19 @@ import {
262
268
  STANDALONE_ROOT,
263
269
  STANDALONE_DATA_DIR,
264
270
  } from './runtime-paths.mjs';
271
+ // Desktop-app browser bridge: tiny fs/fetch client, so a static import adds
272
+ // no meaningful boot cost. The tool itself is gated per session by the sync
273
+ // bridge-availability probe (headless runs never see it).
274
+ import {
275
+ browserBridgeAvailableSync,
276
+ executeBrowserTool,
277
+ } from '../runtime/browser-bridge/client.mjs';
278
+ import { TOOL_DEFS as BROWSER_BRIDGE_TOOL_DEFS } from '../runtime/browser-bridge/tool-defs.mjs';
279
+ import {
280
+ computerBridgeAvailableSync,
281
+ executeComputerTool,
282
+ } from '../runtime/computer-bridge/client.mjs';
283
+ import { TOOL_DEFS as COMPUTER_BRIDGE_TOOL_DEFS } from '../runtime/computer-bridge/tool-defs.mjs';
265
284
  import {
266
285
  dispatchWebSearchRuntimeTool,
267
286
  memoryToolArgsForCaller,
@@ -416,9 +435,15 @@ export async function createMixdogSessionRuntime({
416
435
  const webSearchEnabled = () => featureEnvOverride('MIXDOG_FEATURE_WEB_SEARCH')
417
436
  ?? moduleEnabled(rt.config, 'webSearch', true);
418
437
  const channelsEnabled = () => moduleEnabled(rt.config, 'channels', true);
438
+ const browserToolEnabled = () => featureEnvOverride('MIXDOG_FEATURE_BROWSER')
439
+ ?? browserBridgeAvailableSync();
440
+ const computerToolEnabled = () => featureEnvOverride('MIXDOG_FEATURE_COMPUTER')
441
+ ?? computerBridgeAvailableSync();
419
442
  const featureDisallowedTools = () => [
420
443
  ...(webSearchEnabled() ? [] : ['web_search', 'web_fetch']),
421
444
  ...(memoryToolsEnabledFn() ? [] : ['memory', 'recall']),
445
+ ...(browserToolEnabled() ? [] : ['browser']),
446
+ ...(computerToolEnabled() ? [] : ['computer']),
422
447
  ];
423
448
 
424
449
  async function getMemoryModule() {
@@ -618,7 +643,6 @@ export async function createMixdogSessionRuntime({
618
643
  // True while the boot-time provider-catalog refresh is in flight: warming a
619
644
  // model cache it is about to invalidate only burns the load twice.
620
645
  rt.startupProviderCatalogRefreshPending = false;
621
- rt.lastProjectMcpKey = null;
622
646
  // MCP connect state, owned here so teardown/reconnect paths still observe it;
623
647
  // the mcp-glue factory mutates this object in place (see createMcpGlue).
624
648
  const mcpState = {
@@ -632,6 +656,7 @@ export async function createMixdogSessionRuntime({
632
656
  mcpTransportLabel,
633
657
  resolveEffectiveMcpServers,
634
658
  mcpStatus,
659
+ getMcpServerConfig,
635
660
  connectConfiguredMcp,
636
661
  awaitInitialMcpConnect,
637
662
  normalizeMcpServerInput,
@@ -685,6 +710,7 @@ export async function createMixdogSessionRuntime({
685
710
  // Notification fan-out (listener broadcast + pending-queue mirroring of
686
711
  // terminal completions) lives in notification-bus.mjs.
687
712
  let sessionTurnApi = null;
713
+ let goalRuntime = null;
688
714
  const completionWakeups = new Set();
689
715
  const wakeQueuedCompletion = ({ sessionId, executionId, enqueuedAt } = {}) => {
690
716
  const ownerSessionId = String(sessionId || '').trim();
@@ -743,6 +769,7 @@ export async function createMixdogSessionRuntime({
743
769
  if (v?.id) {
744
770
  rt.reservedSessionId = null;
745
771
  bindRuntimeNotificationSession(v.id);
772
+ goalRuntime?.watchSession(v.id);
746
773
  }
747
774
  };
748
775
 
@@ -752,12 +779,14 @@ export async function createMixdogSessionRuntime({
752
779
  skillsStatus,
753
780
  skillContent,
754
781
  skillToolContent,
755
- addProjectSkill,
782
+ addGlobalSkill,
783
+ saveSkillDocument,
784
+ invalidateSkills,
756
785
  } = createSkillsApi({ contextMod, getCwd: () => rt.currentCwd });
757
786
 
758
787
  // cwd resolution/apply + plugins-status + core-memory context. Extracted to
759
788
  // session-runtime/cwd-plugins.mjs; the facade keeps ownership of the mutable
760
- // currentCwd/session/config/lastProjectMcpKey locals via getter/setter
789
+ // currentCwd/session/config locals via getter/setter
761
790
  // injection and passes the later-defined callbacks (prewarm/tool-surface/
762
791
  // memory) as closures.
763
792
  const {
@@ -771,9 +800,9 @@ export async function createMixdogSessionRuntime({
771
800
  setCurrentCwd: (next) => { rt.currentCwd = next; },
772
801
  getConfig: () => rt.config,
773
802
  getSession: () => rt.session,
803
+ getDesktopSession: () => rt.desktopSession,
804
+ setDesktopSession: (next) => { rt.desktopSession = next; },
774
805
  getRoute: () => rt.route,
775
- getLastProjectMcpKey: () => rt.lastProjectMcpKey,
776
- setLastProjectMcpKey: (next) => { rt.lastProjectMcpKey = next; },
777
806
  isCodeGraphPrewarmLazy: () => codeGraphPrewarmLazy,
778
807
  isCodeGraphFirstTurnPrewarmDone: () => rt.codeGraphFirstTurnPrewarmDone,
779
808
  getCodeGraphPrewarmDelayMs: () => codeGraphPrewarmDelayMs,
@@ -791,8 +820,8 @@ export async function createMixdogSessionRuntime({
791
820
  pluginMcpServerName,
792
821
  mcpScriptForPlugin,
793
822
  countSkillFiles,
794
- readProjectMcpServers,
795
823
  writeLastSessionCwd,
824
+ updateCurrentCwdOverride,
796
825
  clean,
797
826
  resolve,
798
827
  statSync,
@@ -825,6 +854,27 @@ export async function createMixdogSessionRuntime({
825
854
  } catch { /* best-effort: subagent hook must never affect worker lifecycle */ }
826
855
  },
827
856
  });
857
+ const routedAgentTool = {
858
+ ...agentTool,
859
+ execute(args, context = {}) {
860
+ return remoteAgentControlEnabled()
861
+ ? executeRemoteAgentControl(args, context)
862
+ : agentTool.execute(args, context);
863
+ },
864
+ closeAll(reason, scope = {}) {
865
+ if (!remoteAgentControlEnabled()) return agentTool.closeAll(reason, scope);
866
+ void executeRemoteAgentControl({
867
+ type: '__close_all',
868
+ reason: String(reason || 'agent owner closed'),
869
+ }, {
870
+ callerCwd: rt.currentCwd,
871
+ invocationSource: 'runtime-lifecycle',
872
+ callerSessionId: scope?.callerSessionId || rt.session?.id || null,
873
+ clientHostPid: rt.session?.clientHostPid || process.pid,
874
+ }).catch(() => {});
875
+ return undefined;
876
+ },
877
+ };
828
878
  bootProfile('agent:ready', { ms: (performance.now() - agentToolStartedAt).toFixed(1) });
829
879
  const agentStatusState = () => {
830
880
  try {
@@ -841,6 +891,12 @@ export async function createMixdogSessionRuntime({
841
891
  return { agentWorkers: [], agentJobs: [], agentScope: null };
842
892
  }
843
893
  };
894
+ goalRuntime = createGoalRuntime({
895
+ dataDir: cfgMod.getPluginData?.() || STANDALONE_DATA_DIR,
896
+ });
897
+ if (rt.session?.id || rt.reservedSessionId) {
898
+ goalRuntime.watchSession(rt.session?.id || rt.reservedSessionId);
899
+ }
844
900
  const channelsStartedAt = performance.now();
845
901
  const channels = createStandaloneChannelWorker({
846
902
  rootDir: STANDALONE_ROOT,
@@ -876,6 +932,9 @@ export async function createMixdogSessionRuntime({
876
932
  ...(memoryToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'recall' || tool?.name === 'memory'),
877
933
  ...(channelToolDefs?.TOOL_DEFS || []).filter((tool) => channels.isChannelTool(tool?.name)),
878
934
  ...(codeGraphToolDefs?.CODE_GRAPH_TOOL_DEFS || []).filter((tool) => tool?.name === 'code_graph'),
935
+ ...BROWSER_BRIDGE_TOOL_DEFS.filter((tool) => tool?.name === 'browser'),
936
+ ...COMPUTER_BRIDGE_TOOL_DEFS.filter((tool) => tool?.name === 'computer'),
937
+ ...goalRuntime.tools,
879
938
  ...agentTool.tools,
880
939
  ].map(applyStandaloneToolDefaults);
881
940
  bootProfile('tools:ready', { ms: (performance.now() - toolsStartedAt).toFixed(1), count: standaloneTools.length });
@@ -889,6 +948,7 @@ export async function createMixdogSessionRuntime({
889
948
  // replay) lives in tool-surface.mjs.
890
949
  const {
891
950
  modelStandaloneTools,
951
+ activateTools: activateRuntimeTools,
892
952
  invalidatePreSessionToolSurface,
893
953
  activeToolSurface,
894
954
  applyPreSessionToolSelection,
@@ -941,6 +1001,18 @@ export async function createMixdogSessionRuntime({
941
1001
  throw new Error('memory tools are disabled in settings; background memory and manual core memory remain available');
942
1002
  }
943
1003
  }
1004
+ if (name === 'browser') {
1005
+ if (callerCtx?.invocationSource === 'model-tool' && featureEnvOverride('MIXDOG_FEATURE_BROWSER') === false) {
1006
+ throw new Error('the browser tool is disabled in this environment');
1007
+ }
1008
+ return await executeBrowserTool(args);
1009
+ }
1010
+ if (name === 'computer') {
1011
+ if (callerCtx?.invocationSource === 'model-tool' && featureEnvOverride('MIXDOG_FEATURE_COMPUTER') === false) {
1012
+ throw new Error('the computer tool is disabled in this environment');
1013
+ }
1014
+ return await executeComputerTool(args);
1015
+ }
944
1016
  if (name === 'web_search' || name === 'web_fetch' || name === 'local_fetch' || name === 'image_fetch') {
945
1017
  return dispatchWebSearchRuntimeTool(name, args, callerCtx, {
946
1018
  getWebSearchModule,
@@ -978,7 +1050,7 @@ export async function createMixdogSessionRuntime({
978
1050
  if (!stat.isDirectory()) throw new Error(`cwd: not a directory: ${nextCwd}`);
979
1051
  currentCwd = typeof callerCtx?.setCallerCwd === 'function'
980
1052
  ? clean(await callerCtx.setCallerCwd(nextCwd)) || nextCwd
981
- : applyResolvedCwd(nextCwd);
1053
+ : applyResolvedCwd(nextCwd, { persistProjectSelection: true });
982
1054
  } else if (action !== 'get') {
983
1055
  throw new Error(`cwd: unknown action "${action}"`);
984
1056
  }
@@ -990,9 +1062,14 @@ export async function createMixdogSessionRuntime({
990
1062
  if (name === 'Skill') {
991
1063
  return skillToolContent(args?.name);
992
1064
  }
1065
+ if (name === 'get_goal' || name === 'create_goal' || name === 'update_goal') {
1066
+ return await goalRuntime.executeTool(name, args || {}, {
1067
+ callerSessionId: callerCtx?.callerSessionId || rt.session?.id || rt.reservedSessionId || null,
1068
+ });
1069
+ }
993
1070
  if (name === 'agent') {
994
1071
  const callerSessionId = callerCtx?.callerSessionId || rt.session?.id || null;
995
- return await agentTool.execute(args, {
1072
+ return await routedAgentTool.execute(args, {
996
1073
  callerCwd,
997
1074
  invocationSource: 'model-tool',
998
1075
  callerSessionId,
@@ -1009,7 +1086,6 @@ export async function createMixdogSessionRuntime({
1009
1086
  },
1010
1087
  });
1011
1088
  internalTools.markBootReady?.();
1012
- try { rt.lastProjectMcpKey = resolve(rt.currentCwd) + '\u0000' + JSON.stringify(readProjectMcpServers(rt.currentCwd)); } catch { rt.lastProjectMcpKey = null; }
1013
1089
  void connectConfiguredMcp()
1014
1090
  .then((status) => bootProfile('mcp:ready', {
1015
1091
  connected: Number(status?.connectedCount || 0),
@@ -1301,7 +1377,7 @@ export async function createMixdogSessionRuntime({
1301
1377
  channelStartDelayMs,
1302
1378
  codeGraphPrewarmEnabled,
1303
1379
  prewarmState,
1304
- agentTool,
1380
+ agentTool: routedAgentTool,
1305
1381
  });
1306
1382
  const ensureSessionTranscriptWriter = () => remoteTranscript.ensureSessionTranscriptWriter();
1307
1383
 
@@ -1322,8 +1398,8 @@ export async function createMixdogSessionRuntime({
1322
1398
  providerWarmup: providerWarmupEnabled,
1323
1399
  codeGraphPrewarm: codeGraphPrewarmEnabled,
1324
1400
  });
1325
- // Heavy session/tool/code-graph work is demand-driven. Startup restores the
1326
- // visible pane tree only; the first real turn arms these warmups.
1401
+ // Heavy work remains demand-driven. Native helpers overlap provider.send;
1402
+ // memory and code-graph parsing stay cold until their feature is used.
1327
1403
  bootProfile('runtime:prewarm-deferred', { reason: 'first-turn' });
1328
1404
  scheduleProviderWarmup();
1329
1405
  scheduleProviderModelWarmup();
@@ -1443,6 +1519,7 @@ export async function createMixdogSessionRuntime({
1443
1519
  mgr.updateSessionGeneratedTitle(sessionId, title, stage)
1444
1520
  ),
1445
1521
  });
1522
+ let disposeGlobalExtensionSubscription = () => {};
1446
1523
  const lifecycleApi = createLifecycleApi({
1447
1524
  getSession: () => rt.session,
1448
1525
  setSession: adoptSession,
@@ -1464,7 +1541,7 @@ export async function createMixdogSessionRuntime({
1464
1541
  mgr,
1465
1542
  statusRoutes,
1466
1543
  channels,
1467
- agentTool,
1544
+ agentTool: routedAgentTool,
1468
1545
  mcpClient,
1469
1546
  warmupTimers,
1470
1547
  prewarmTimers,
@@ -1489,7 +1566,9 @@ export async function createMixdogSessionRuntime({
1489
1566
  // workflow's agent-tool gate, not reuse the boot-time array.
1490
1567
  getStandaloneTools: modelStandaloneTools,
1491
1568
  clearRuntimeNotifications,
1569
+ goalRuntime,
1492
1570
  disposeSessionTitles: () => sessionTitles.disposeAll(),
1571
+ disposeGlobalExtensionSubscription: () => disposeGlobalExtensionSubscription(),
1493
1572
  });
1494
1573
  const resourceApi = createResourceApi({
1495
1574
  getConfig: () => rt.config,
@@ -1505,15 +1584,22 @@ export async function createMixdogSessionRuntime({
1505
1584
  recreateCurrentSessionIfReady,
1506
1585
  normalizeMcpServerInput,
1507
1586
  mcpStatus,
1587
+ getMcpServerConfig,
1508
1588
  skillsStatus,
1509
1589
  skillContent,
1510
- addProjectSkill,
1590
+ addGlobalSkill,
1591
+ saveSkillDocument,
1592
+ invalidateSkills,
1593
+ getDisabledSkills: () => settingsApi.getDisabledSkills(),
1594
+ setDisabledSkills: (names) => settingsApi.setDisabledSkills(names),
1511
1595
  pluginsStatus,
1512
1596
  getMemoryModule,
1513
1597
  reloadFullConfig,
1598
+ flushSkillsSave,
1514
1599
  awaitKeychainPrewarm,
1515
1600
  getActiveTurnCount: () => rt.activeTurnCount,
1516
1601
  });
1602
+ disposeGlobalExtensionSubscription = () => resourceApi.disposeGlobalExtensionSubscription?.();
1517
1603
  const modelRouteApi = createModelRouteApi({
1518
1604
  getConfig: () => rt.config,
1519
1605
  getRoute: () => rt.route,
@@ -1609,7 +1695,7 @@ export async function createMixdogSessionRuntime({
1609
1695
  scheduleProviderWarmup,
1610
1696
  scheduleProviderModelWarmup,
1611
1697
  invalidateContextStatusCache,
1612
- agentTool,
1698
+ agentTool: routedAgentTool,
1613
1699
  recreateCurrentSessionIfReady,
1614
1700
  invalidatePreSessionToolSurface,
1615
1701
  activeToolSurface,
@@ -1636,18 +1722,26 @@ export async function createMixdogSessionRuntime({
1636
1722
  rt.session?.id,
1637
1723
  options,
1638
1724
  ),
1639
- revertTurnReview: () => revertTurnSnapshotReview(
1725
+ revertTurnReview: (checkpointId) => revertTurnSnapshotReview(
1640
1726
  rt.currentCwd,
1641
1727
  rt.session?.id,
1728
+ checkpointId,
1642
1729
  ),
1643
- revertTurnReviewFile: (file) => revertTurnSnapshotReviewFile(
1730
+ revertTurnReviewFile: (file, checkpointId) => revertTurnSnapshotReviewFile(
1644
1731
  rt.currentCwd,
1645
1732
  rt.session?.id,
1646
1733
  file,
1734
+ checkpointId,
1647
1735
  ),
1648
1736
  get id() {
1649
1737
  return rt.session?.id || rt.reservedSessionId || null;
1650
1738
  },
1739
+ deliverToolCompletion(sessionId, text, meta = {}) {
1740
+ const target = String(sessionId || '').trim();
1741
+ const current = String(rt.session?.id || rt.reservedSessionId || '').trim();
1742
+ if (!target || target !== current) return false;
1743
+ return notifySessionCompletion(target, text, meta);
1744
+ },
1651
1745
  reserveSessionId(sessionId) {
1652
1746
  const id = String(sessionId || '').trim();
1653
1747
  if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) {
@@ -1658,6 +1752,7 @@ export async function createMixdogSessionRuntime({
1658
1752
  }
1659
1753
  rt.reservedSessionId = id;
1660
1754
  bindRuntimeNotificationSession(id);
1755
+ goalRuntime?.watchSession(id);
1661
1756
  // Reservation is the earliest safe point to prepare keychain, memory,
1662
1757
  // provider metadata, hooks, and the provider transport. This starts no
1663
1758
  // model response and therefore incurs no inference/token usage. The first
@@ -1669,6 +1764,39 @@ export async function createMixdogSessionRuntime({
1669
1764
  });
1670
1765
  return id;
1671
1766
  },
1767
+ goalStatus() {
1768
+ const sessionId = rt.session?.id || rt.reservedSessionId || null;
1769
+ return sessionId ? goalRuntime.snapshot(sessionId) : null;
1770
+ },
1771
+ async goalControl(args = {}) {
1772
+ let sessionId = rt.session?.id || rt.reservedSessionId || null;
1773
+ if (!sessionId) {
1774
+ await createCurrentSession('goal');
1775
+ sessionId = rt.session?.id || rt.reservedSessionId || null;
1776
+ }
1777
+ if (!sessionId) throw new Error('goal: session could not be created');
1778
+ const result = await goalRuntime.control(sessionId, args);
1779
+ if (result?.goal?.status === 'active') {
1780
+ activateRuntimeTools(goalRuntime.tools.map((tool) => tool.name));
1781
+ }
1782
+ return result;
1783
+ },
1784
+ goalContinuation() {
1785
+ const sessionId = rt.session?.id || rt.reservedSessionId || null;
1786
+ if (!sessionId) return { run: false, reason: 'missing-session', goal: null };
1787
+ const continuation = goalRuntime.continuation(sessionId, { agentStatus: agentStatusState() });
1788
+ if (continuation.run) {
1789
+ activateRuntimeTools(goalRuntime.tools.map((tool) => tool.name));
1790
+ }
1791
+ return continuation;
1792
+ },
1793
+ archiveCompletedGoalOnUserInput() {
1794
+ const sessionId = rt.session?.id || rt.reservedSessionId || null;
1795
+ return sessionId ? goalRuntime.archiveCompletedOnUserInput(sessionId) : null;
1796
+ },
1797
+ onGoalStatusChange(listener) {
1798
+ return goalRuntime.subscribe(listener);
1799
+ },
1672
1800
  get provider() {
1673
1801
  return rt.route.provider;
1674
1802
  },
@@ -5,7 +5,9 @@
5
5
  import { envDelayMs, envFlag, envPresent } from './env.mjs';
6
6
 
7
7
  export function readRuntimeTunables() {
8
- const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
8
+ const codeGraphPrewarmEager = envFlag('MIXDOG_CODE_GRAPH_PREWARM_EAGER');
9
+ const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM')
10
+ && (envFlag('MIXDOG_ENABLE_CODE_GRAPH_PREWARM') || codeGraphPrewarmEager);
9
11
  const daemonHosted = envFlag('MIXDOG_DAEMON_HOST');
10
12
  return {
11
13
  providerSetupWarmupDelayMs: envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 }),
@@ -50,12 +52,8 @@ export function readRuntimeTunables() {
50
52
  && !envFlag('MIXDOG_DISABLE_MODEL_PREFETCH'),
51
53
  codeGraphPrewarmEnabled,
52
54
  modelCatalogWarmupEnabled: !envFlag('MIXDOG_DISABLE_MODEL_CATALOG_WARMUP'),
53
- // Lazy code-graph prewarm (default ON): do NOT prewarm at startup / on cwd
54
- // change — that fired ~250ms after the first frame and, in a large tree,
55
- // burned a worker (and felt like a freeze) before the user did anything.
56
- // Instead prewarm ONCE on the first real turn, when a code lookup is
57
- // actually imminent. MIXDOG_CODE_GRAPH_PREWARM_EAGER=1 restores the old
58
- // eager behavior.
59
- codeGraphPrewarmLazy: codeGraphPrewarmEnabled && !envFlag('MIXDOG_CODE_GRAPH_PREWARM_EAGER'),
55
+ // Code-graph parsing is demand-driven by default. Operators may opt into
56
+ // first-turn warming, or explicitly restore eager cwd warming.
57
+ codeGraphPrewarmLazy: codeGraphPrewarmEnabled && !codeGraphPrewarmEager,
60
58
  };
61
59
  }
@@ -15,6 +15,7 @@ import { beginTurnSnapshot, cancelTurnSnapshot, completeTurnSnapshot } from '../
15
15
  import { isVisibleStreamProgress } from '../runtime/shared/stream-progress.mjs';
16
16
  import { runAbortable, settleWithin, throwIfAborted } from '../runtime/shared/abort-race.mjs';
17
17
  import { interruptTaskWaitForSession } from '../runtime/agent/orchestrator/session/task-wait-control.mjs';
18
+ import { runWithCwdOverride } from '../runtime/shared/user-cwd.mjs';
18
19
 
19
20
  export function splitToolStatusCounts(rows) {
20
21
  const list = Array.isArray(rows) ? rows : [];
@@ -78,6 +79,7 @@ export function createSessionTurnApi(deps) {
78
79
  };
79
80
  },
80
81
  async ask(prompt, options = {}) {
82
+ return runWithCwdOverride(getCurrentCwd(), async () => {
81
83
  // Remote-attach: this surface is a viewer on a session that another
82
84
  // live process owns. Never run a turn here — persist the prompt into
83
85
  // the shared pending spool; the owner's injection poller submits it as
@@ -123,24 +125,26 @@ export function createSessionTurnApi(deps) {
123
125
  // path. Every actual tool execution joins this same promise below, so
124
126
  // shell/apply_patch cannot mutate the worktree before the baseline.
125
127
  turnSnapshotPromise = Promise.resolve()
126
- .then(() => beginTurnSnapshotForTurn(getCurrentCwd(), id))
128
+ .then(() => beginTurnSnapshotForTurn(getCurrentCwd(), id, {
129
+ // The first submitted row is the outer prompt. Mid-loop steering
130
+ // is drained inside this ask() and deliberately keeps this ID.
131
+ checkpointId: String(options.id || '').trim(),
132
+ }))
127
133
  .catch(() => undefined);
128
134
  };
129
135
  const routeStartedAt = performance.now();
130
136
  startTurnSnapshot(getSession()?.id || getReservedSessionId?.());
131
137
  let routeWaitMs = 0;
132
138
  setActiveTurnCount(getActiveTurnCount() + 1);
133
- try { scheduleSearchRuntimeWarmup?.(0); } catch { /* search warmup is best-effort */ }
134
139
  let mcpWaitMs = 0;
135
140
  let providerStartedAt = 0;
136
141
  let turnTimingStatus = 'error';
137
142
  let turnTimingEmitted = false;
138
- // Heavy runtime warmup is one-shot and demand-driven: idle desktop panes
139
- // never spawn shell/token/search helpers or a code-graph worker. More
140
- // importantly, do not start them until the provider has produced visible
141
- // text/reasoning/tool progress: transport headers and response-created
142
- // acknowledgements arrive earlier and would make PowerShell + graph
143
- // workers compete with the actual first-token critical path.
143
+ // First-turn fallback for hosts that disabled or have not completed the
144
+ // post-connect idle warmup. Do not start it until the provider has
145
+ // produced visible text/reasoning/tool progress: transport headers and
146
+ // response-created acknowledgements arrive earlier and would make
147
+ // PowerShell + graph workers compete with the first-token critical path.
144
148
  const heavyRuntimeWarmupPending = typeof getCodeGraphFirstTurnPrewarmDone === 'function'
145
149
  && !getCodeGraphFirstTurnPrewarmDone();
146
150
  if (heavyRuntimeWarmupPending) {
@@ -211,7 +215,7 @@ export function createSessionTurnApi(deps) {
211
215
  // can update the caller instead of whichever runtime registered the
212
216
  // shared internal-tool executor most recently.
213
217
  Object.defineProperty(session0, '_applyResolvedCwdForCaller', {
214
- value: applyResolvedCwd,
218
+ value: (nextCwd) => applyResolvedCwd(nextCwd, { persistProjectSelection: true }),
215
219
  enumerable: false,
216
220
  configurable: true,
217
221
  writable: true,
@@ -354,7 +358,13 @@ export function createSessionTurnApi(deps) {
354
358
  // after session locking, history shaping and pre-send compact.
355
359
  // The old timestamp above mgr.askSession mislabeled all of that
356
360
  // Mixdog work as provider latency.
357
- if (!providerStartedAt) providerStartedAt = performance.now();
361
+ if (!providerStartedAt) {
362
+ providerStartedAt = performance.now();
363
+ // Warm the two small native helpers only after provider.send
364
+ // owns the request. Their startup overlaps network/model time
365
+ // without competing with route, snapshot, or compact work.
366
+ try { scheduleSearchRuntimeWarmup?.(0); } catch { /* best-effort */ }
367
+ }
358
368
  return options.onProviderSendStarted?.(...args);
359
369
  },
360
370
  onToolPhaseStarted: options.onToolPhaseStarted,
@@ -444,6 +454,7 @@ export function createSessionTurnApi(deps) {
444
454
  scheduleProviderModelWarmup();
445
455
  }
446
456
  }
457
+ });
447
458
  },
448
459
  async clear(options = {}) {
449
460
  const session = getSession();
@@ -622,7 +633,7 @@ export function createSessionTurnApi(deps) {
622
633
  return { ...result, status: this.toolsStatus() };
623
634
  },
624
635
  setCwd(path) {
625
- applyResolvedCwd(resolveCwdPath(path));
636
+ applyResolvedCwd(resolveCwdPath(path), { persistProjectSelection: true });
626
637
  return getCurrentCwd();
627
638
  },
628
639
  };
@@ -1,34 +1,31 @@
1
- // Skill surface (status listing, resource load, tool envelope, project skill
2
- // creation). Extracted from runtime-core so the facade only wires the mutable
1
+ // Skill surface (status listing, resource load, tool envelope, global skill
2
+ // creation/editing). Extracted from runtime-core so the facade only wires the mutable
3
3
  // cwd and the context module into it.
4
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
- import { join } from 'node:path';
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
+ import { resolvePluginData } from '../runtime/shared/plugin-paths.mjs';
7
+ import {
8
+ createSkillDocument,
9
+ updateSkillDocument,
10
+ validateSkillDescription,
11
+ validateSkillName,
12
+ } from '../runtime/shared/skill-document.mjs';
6
13
  import { clean } from './session-text.mjs';
7
14
 
8
- const SKILL_TEMPLATE = (name, description) => [
9
- '---',
10
- `name: ${name}`,
11
- `description: ${description}`,
12
- '---',
13
- '',
14
- '# Instructions',
15
- '',
16
- 'Describe when and how to use this skill.',
17
- '',
18
- ].join('\n');
15
+ const DEFAULT_SKILL_BODY = '# Instructions\n\nDescribe how to use this skill.';
19
16
 
20
17
  export function createSkillsApi({ contextMod, getCwd }) {
18
+ const globalSkillsRoot = () => resolve(resolvePluginData(), 'skills');
19
+
21
20
  function skillsStatus() {
22
21
  const cwd = getCwd();
23
22
  const skills = typeof contextMod.collectSkillsCached === 'function'
24
23
  ? contextMod.collectSkillsCached(cwd)
25
24
  : [];
26
25
  const norm = (value) => String(value || '').replace(/\\/g, '/').toLowerCase();
27
- const cwdNorm = norm(cwd);
28
- // A skill under <cwd>/.mixdog/skills is project-scoped; everything else
29
- // comes from the user/global tree.
26
+ const globalRoot = `${norm(globalSkillsRoot())}/`;
30
27
  const sourceForSkill = (filePath) => (
31
- cwdNorm && norm(filePath).startsWith(`${cwdNorm}/.mixdog/skills/`) ? 'project' : 'skill'
28
+ norm(filePath).startsWith(globalRoot) ? 'global' : 'plugin'
32
29
  );
33
30
  return {
34
31
  cwd,
@@ -38,6 +35,7 @@ export function createSkillsApi({ contextMod, getCwd }) {
38
35
  description: skill.description || '',
39
36
  filePath: skill.filePath || null,
40
37
  source: sourceForSkill(skill.filePath),
38
+ editable: sourceForSkill(skill.filePath) === 'global',
41
39
  })),
42
40
  };
43
41
  }
@@ -65,16 +63,72 @@ export function createSkillsApi({ contextMod, getCwd }) {
65
63
  return contextMod.buildSkillToolEnvelope(skill.name, skill.content, skill.dir);
66
64
  }
67
65
 
68
- function addProjectSkill(input = {}) {
69
- const name = clean(input.name).replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
70
- if (!name) throw new Error('skill name is required');
71
- const dir = join(getCwd(), '.mixdog', 'skills', name);
66
+ function addGlobalSkill(input = {}) {
67
+ const name = validateSkillName(clean(input.name));
68
+ const description = validateSkillDescription(input.description);
69
+ const body = String(input.instructions || input.body || DEFAULT_SKILL_BODY);
70
+ const dir = join(globalSkillsRoot(), name);
72
71
  const filePath = join(dir, 'SKILL.md');
73
72
  if (existsSync(filePath)) throw new Error(`skill already exists: ${name}`);
74
73
  mkdirSync(dir, { recursive: true });
75
- writeFileSync(filePath, SKILL_TEMPLATE(name, clean(input.description) || 'Project skill.'), 'utf8');
74
+ writeFileSync(filePath, createSkillDocument({ name, description, body }), 'utf8');
75
+ contextMod.invalidateSkillsCache?.(getCwd());
76
76
  return { name, filePath };
77
77
  }
78
78
 
79
- return { skillsStatus, skillContent, skillToolContent, addProjectSkill };
79
+ function saveSkillDocument(input = {}) {
80
+ const originalName = validateSkillName(input.originalName);
81
+ const name = validateSkillName(input.name);
82
+ const description = validateSkillDescription(input.description);
83
+ const body = String(input.instructions || input.body || '');
84
+ const resource = contextMod.loadSkillResource?.(originalName, getCwd());
85
+ if (!resource?.filePath) throw new Error(`skill not found: ${originalName}`);
86
+ const resourcePath = resolve(resource.filePath);
87
+ const resourceRelative = relative(globalSkillsRoot(), resourcePath);
88
+ if (!resourceRelative || resourceRelative.startsWith('..') || isAbsolute(resourceRelative)) {
89
+ throw new Error(`plugin skill is read-only: ${originalName}`);
90
+ }
91
+ const collision = skillsStatus().skills.find((skill) =>
92
+ skill.name === name && skill.filePath !== resource.filePath);
93
+ if (collision) throw new Error(`skill already exists: ${name}`);
94
+
95
+ const currentDir = dirname(resource.filePath);
96
+ if (basename(currentDir) !== originalName) {
97
+ throw new Error(`skill folder does not match its name: ${originalName}`);
98
+ }
99
+ const nextDir = join(dirname(currentDir), name);
100
+ if (nextDir !== currentDir && existsSync(nextDir)) {
101
+ throw new Error(`skill folder already exists: ${name}`);
102
+ }
103
+ const source = readFileSync(resource.filePath, 'utf8');
104
+ const updated = updateSkillDocument(source, { name, description, body });
105
+ let filePath = resource.filePath;
106
+ if (nextDir !== currentDir) {
107
+ renameSync(currentDir, nextDir);
108
+ filePath = join(nextDir, 'SKILL.md');
109
+ try {
110
+ writeFileSync(filePath, updated, 'utf8');
111
+ } catch (error) {
112
+ renameSync(nextDir, currentDir);
113
+ throw error;
114
+ }
115
+ } else {
116
+ writeFileSync(filePath, updated, 'utf8');
117
+ }
118
+ contextMod.invalidateSkillsCache?.(getCwd());
119
+ return { originalName, name, filePath };
120
+ }
121
+
122
+ function invalidateSkills() {
123
+ contextMod.invalidateSkillsCache?.(getCwd());
124
+ }
125
+
126
+ return {
127
+ skillsStatus,
128
+ skillContent,
129
+ skillToolContent,
130
+ addGlobalSkill,
131
+ saveSkillDocument,
132
+ invalidateSkills,
133
+ };
80
134
  }