mixdog 1.0.0 → 1.0.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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/defaults/skills/computer-use/SKILL.md +13 -12
  3. package/src/defaults/skills/setup/SKILL.md +1 -4
  4. package/src/defaults/skills/setup/references/actions.md +6 -5
  5. package/src/defaults/skills/setup/references/surfaces.md +9 -6
  6. package/src/rules/shared/70-delivery.md +3 -0
  7. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +5 -0
  8. package/src/runtime/agent/orchestrator/config.mjs +8 -1
  9. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +0 -1
  10. package/src/runtime/agent/orchestrator/providers/registry-errors.mjs +9 -0
  11. package/src/runtime/agent/orchestrator/providers/registry.mjs +3 -2
  12. package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +13 -5
  13. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +3 -2
  14. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +27 -4
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +3 -1
  19. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  20. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +13 -5
  21. package/src/runtime/computer-bridge/error-recovery.mjs +1 -1
  22. package/src/runtime/shared/config-patch.mjs +2 -0
  23. package/src/runtime/shared/user-data-guard.mjs +1 -1
  24. package/src/session-runtime/config-lifecycle.mjs +11 -1
  25. package/src/session-runtime/cwd-plugins/core-memory-context.mjs +1 -1
  26. package/src/session-runtime/cwd-plugins/plugin-status.mjs +24 -4
  27. package/src/session-runtime/native-web-search/tool-shape.mjs +16 -4
  28. package/src/session-runtime/provider-models/catalog-load.mjs +18 -3
  29. package/src/session-runtime/setup-tool/executor.mjs +0 -1
  30. package/src/session-runtime/setup-tool/settings-contract.mjs +2 -13
  31. package/src/session-runtime/setup-tool/tool-defs.mjs +1 -1
  32. package/src/session-runtime/workflow-agents-api/agent-route.mjs +8 -5
  33. package/src/tui/app/doctor.mjs +78 -2
  34. package/src/tui/dist/index.mjs +1 -1
  35. package/src/workflows/default/WORKFLOW.md +0 -2
  36. package/src/workflows/headless/WORKFLOW.md +0 -2
  37. package/scripts/lib/bench-sandbox.test.mjs +0 -54
  38. package/scripts/lib/cli-args.test.mjs +0 -64
  39. package/scripts/lib/embedding-model-bench-core.test.mjs +0 -47
  40. package/scripts/lib/smoke-loop-shared.test.mjs +0 -22
  41. package/scripts/lib/trace-row.test.mjs +0 -47
  42. package/scripts/lib/trace-stats.test.mjs +0 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -31,7 +31,7 @@
31
31
  "!scripts/bench/",
32
32
  "!scripts/recall-bench-*.txt",
33
33
  "!scripts/*-test.mjs",
34
- "!scripts/*.test.mjs",
34
+ "!scripts/**/*.test.mjs",
35
35
  "!scripts/tool-contracts/",
36
36
  "!scripts/provider-toolcall/",
37
37
  "!scripts/session-transport/",
@@ -115,18 +115,19 @@ GNOME with the Window Calls extension); prefer accessibility refs there.
115
115
  of the shared desktop. If that surface is unavailable, use semantic refs or
116
116
  report `pixel_unavailable`; do not substitute a screen grab.
117
117
  - If the user intervenes, preserve their cursor and focus. Worker termination
118
- and input cleanup must finish before resuming; a failed cleanup is not cleared
119
- by a resume request. Ask the user to press `Ctrl+Alt+Esc`, or the overlay's
120
- Stop control, for verified cleanup recovery.
121
- If target-local release remains unconfirmed, the user must inspect/recover
122
- that window; restarting the host alone does not prove release. Obtain a new
123
- observation after an interruption.
124
- Use `wait_for_user` to keep the task waiting without sending input. The host
125
- may resume ordinary physical-input interruptions after its configured quiet
126
- interval; renewed input resets that interval. Explicit pauses/stops and
127
- uncertain observation/cleanup never auto-resume. The overlay's only control
128
- is Stop, which cancels the task. Never operate it or the recovery controls,
129
- or change the idle policy, on the user's behalf.
118
+ and input cleanup must finish before resuming. The user's Resume on the
119
+ overlay continues a pause; an ordinary physical-input pause may also resume
120
+ after the host's configured quiet interval (renewed input resets it), while
121
+ explicit pauses, stops and uncertain cleanup never auto-resume. Stop (or
122
+ `Ctrl+Alt+Esc`) cancels the task, which then reports the stop as a failure.
123
+ A cleanup that could not be confirmed is retried before the next input
124
+ command; if it still fails, that command returns the reason — relay it to
125
+ the user. If target-local release remains unconfirmed, the user must
126
+ inspect/recover that window; restarting the host alone does not prove
127
+ release. Obtain a new observation after an interruption.
128
+ Use `wait_for_user` to keep the task waiting without sending input. Never
129
+ operate Resume, Stop or the recovery controls, or change the idle policy, on
130
+ the user's behalf.
130
131
  While paused, only `list`, `diagnose` and `wait_for_user` are available.
131
132
  Chat text alone does not clear the host. After `resumed`, capture fresh state;
132
133
  after `timeout` or `cancelled`, no input is authorized. Never replay the
@@ -25,7 +25,7 @@ supported `setup` action, and read its result as the change receipt.
25
25
  arguments and the transcript. Open the Providers surface and let the user
26
26
  enter them.
27
27
  - A mutation follows the active workflow's approval rules. Status, catalog,
28
- definition, MCP-config and Instructions reads do not change persisted settings.
28
+ definition and MCP-config reads do not change persisted settings.
29
29
  - Most changes affect new sessions. Report `appliedToCurrentSession` and
30
30
  `appliesTo` exactly when the result supplies them.
31
31
  - This skill does not own source edits, builds, installation deployment,
@@ -78,9 +78,6 @@ that a call returned without transport error.
78
78
  - Read definitions before editing them; use skill-creator when authoring a
79
79
  skill. Setup persists the supported definition fields. Workflow and agent
80
80
  ids are stable identities, distinct from their display names.
81
- - Read Instructions before replacing them and pass the returned text as
82
- `expectedContent`. `projectPath:null` means Common Instructions; otherwise
83
- use an exact registered Project root. Keep the returned backup.
84
81
  - MCP servers and plugins are machine-global integrations whose visibility may
85
82
  be scoped to selected Projects.
86
83
  - User skills are machine-global. Enabled plugin skills and built-in skills are
@@ -64,13 +64,11 @@ Read this when choosing a `setup` mutation.
64
64
  | Delete an existing schedule/webhook | `delete_automation` |
65
65
  | Enable/disable a schedule/webhook | `set_automation_enabled` |
66
66
  | Webhook listener enabled state, port, or domain | `set_webhook_config` |
67
- | Desktop keep-awake, usage pin, or Computer observe-only state | `set_desktop_settings` |
67
+ | Desktop keep-awake, run in background after window close, usage pin, or Computer observe-only state | `set_desktop_settings` |
68
68
  | Desktop theme, display language, side panels, or zoom | `set_appearance` |
69
69
  | Register a Project and optionally set its display alias | `save_project` |
70
70
  | Unregister a Project without deleting its files | `remove_project` |
71
- | Read Project/Common Instructions before editing | `get_instructions` |
72
- | Replace unchanged Project/Common Instructions and retain a backup | `set_instructions` |
73
- | Revoke one linked device; re-pairing restores access | `revoke_linked_device` |
71
+ | Revoke one linked device by its client id from `status connection`; re-pairing restores access | `revoke_linked_device` |
74
72
  | Managed local model context allocation; null restores automatic | `set_local_context` |
75
73
 
76
74
  ## Routes and workflow
@@ -118,7 +116,10 @@ values through chat.
118
116
  ## Built-in features
119
117
 
120
118
  Read `status features` and distinguish installation, enabled state, and live
121
- bridge state.
119
+ bridge state. `status features` covers Git, Memory, Office, Code Tidy, Local
120
+ Provider and Web Search plus Browser/Computer bridge activity; voice status and
121
+ the Browser Use / Computer Use installed and enabled markers are read with
122
+ `status desktop`.
122
123
 
123
124
  - Git, Memory, Office, and Local Provider are install-first capabilities.
124
125
  - Git and Office installation run on the Desktop when this conversation is
@@ -37,9 +37,9 @@ An attached Desktop or TUI may navigate and return `opened:true`.
37
37
  - Settings → Providers: API-key, OAuth, local providers, and usage sign-in.
38
38
  - Extensions → Plugin → Git & GitHub: Git/GitHub CLI installation and connection.
39
39
  - Settings → Connection: web-app pairing and linked devices.
40
- - Settings → System: update, keep-awake, and Doctor.
41
- - Projects: Project registration, name, Instructions, Common Instructions, and
42
- Core Memories.
40
+ - Settings → System: update, keep-awake, run in background after the window
41
+ closes, and Doctor.
42
+ - Projects: Project registration, name, Project Memories, and Common Memory.
43
43
  - Workflows: workflow packs, Main and Web Search defaults, and agent editing.
44
44
  - Extensions → Plugin: Built-in cards and plugins.
45
45
  - Extensions → Skill: skills and MCP servers.
@@ -49,12 +49,15 @@ the user to Desktop.
49
49
 
50
50
  ## Desktop-backed settings
51
51
 
52
- Setup can read and change Desktop appearance, keep-awake, usage pin,
53
- Computer observe-only, Browser/Computer/voice installation and toggles,
54
- Projects, Instructions, and linked-device revocation through the attached
52
+ Setup can read and change Desktop appearance, keep-awake, run in background,
53
+ usage pin, Computer observe-only, Browser/Computer/voice installation and
54
+ toggles, Projects, and linked-device revocation through the attached
55
55
  Desktop. A receipt identifies `scope:desktop-host`; appearance is not applied
56
56
  to the paired browser. No Desktop claimant means no confirmed change.
57
57
 
58
+ Computer observe-only has no Desktop settings control; setup is its only
59
+ supported route. Usage pin is the pin toggle on the activity rail usage card.
60
+
58
61
  Schedules/webhooks and workflow/agent/skill definitions use their runtime
59
62
  actions directly. These do not require clicking their Desktop rails.
60
63
 
@@ -7,6 +7,9 @@
7
7
  absolute path, optionally `:line[:column]` (`deliverables/report.docx`,
8
8
  `src/app.ts:42`); never a bare name, a path relative to another folder, or a
9
9
  `file://` URI.
10
+ - Deliver requested visual artifacts (SVG, diagrams, HTML pages) as saved files
11
+ and give their path; never paste their source as the answer unless the user
12
+ asks for the code.
10
13
 
11
14
  <!-- tools: git -->
12
15
  - A commit request includes selecting and staging its changes; a stage-only
@@ -410,6 +410,11 @@ function classifyToolFailure(resultText, toolName) {
410
410
  if (/\[signal:\s*[^\]]+/i.test(leading)) return 'process/signal';
411
411
  return 'command-exit';
412
412
  }
413
+ // edit quotes the file around a miss (the nearest line and a current-file
414
+ // excerpt). That quoted text is file content, so a word in it such as
415
+ // "unreadable", "timeout" or "permission" must not pick the category.
416
+ if (toolName === 'edit' && /^edit failed \(old_string (?:not found|found \d+ times)\b/i.test(leading))
417
+ return 'runtime/failure';
413
418
  if (/compacted-history placeholder/.test(text)) return 'expected-preflight';
414
419
  if (/\[tool-input-validation\]/.test(text)) return 'schema/args';
415
420
  // These are emitted validation/cancellation contracts, not keywords found
@@ -100,7 +100,9 @@ export function buildDefaultConfig(options = {}) {
100
100
  providers['mixdog-local'] = { enabled: false };
101
101
  return {
102
102
  providers,
103
- disabledAgents: [...DEFAULT_DISABLED_AGENT_IDS],
103
+ // The Maintainer also starts off: background upkeep spends model calls, so
104
+ // it runs only once the user turns it on and picks its model.
105
+ disabledAgents: [...DEFAULT_DISABLED_AGENT_IDS, 'maintainer'],
104
106
  workflow: { active: 'default' },
105
107
  orchestrationMode: 'none',
106
108
  };
@@ -272,6 +274,11 @@ export function loadConfig(options = {}) {
272
274
  recap: { enabled: true },
273
275
  developer: {},
274
276
  modules: {},
277
+ // No stored section at all is a brand-new profile: its built-ins start
278
+ // uninstalled. The defaults above carry presets/recap/modules, which
279
+ // would otherwise read as a pre-section profile and be grandfathered.
280
+ // A stored section that failed to parse proves nothing and keeps that path.
281
+ ...(hasKeys(sectionRaw) ? {} : { builtins: {} }),
275
282
  };
276
283
  }
277
284
  /** In-lock patch of `skills.disabled` only (avoids whole-config lost-update). */
@@ -281,7 +281,6 @@ export function composeSystemPrompt(opts) {
281
281
  // pre-tool preamble toward English.
282
282
  const environmentParts = [
283
283
  opts.sessionStartContext,
284
- opts.projectInstructionsContext,
285
284
  opts.environmentContext,
286
285
  opts.languageContext,
287
286
  ].map(trimmedPromptSlice);
@@ -0,0 +1,9 @@
1
+ // Error code for a provider init that has no enabled provider to register.
2
+ // The registry refuses such an init to protect its live instances; readers
3
+ // that legitimately run before any provider exists (the model catalog on a
4
+ // fresh install) treat it as "nothing to load" instead of a failure.
5
+ export const NO_ENABLED_PROVIDERS = 'PROVIDERS_NONE_ENABLED';
6
+
7
+ export function noEnabledProvidersError(message) {
8
+ return Object.assign(new Error(message), { code: NO_ENABLED_PROVIDERS });
9
+ }
@@ -4,6 +4,7 @@ import { refreshCatalog as refreshMetadataCatalog } from './model-catalog.mjs';
4
4
  import { wrapProviderAdmission } from './admission-scheduler.mjs';
5
5
  import { createAccountPoolProvider } from './account-pool.mjs';
6
6
  import { isInclusiveProvider } from '../../../shared/llm/cost.mjs';
7
+ import { noEnabledProvidersError } from './registry-errors.mjs';
7
8
  // OpenAI-compat provider names are self-declared by openai-compat-presets.mjs via
8
9
  // OPENAI_COMPAT_PRESETS. No parallel list maintained here.
9
10
  const providers = new Map();
@@ -223,7 +224,7 @@ async function _initProvidersUnsynchronized(config, signal = null) {
223
224
  // here preserves whatever was already registered.
224
225
  const entries = Object.entries(config || {});
225
226
  if (entries.length === 0) {
226
- throw new Error('[provider] initProviders called with empty config — refusing to clear registry');
227
+ throw noEnabledProvidersError('[provider] initProviders called with empty config — refusing to clear registry');
227
228
  }
228
229
  const next = new Map();
229
230
  const nextSignatures = new Map();
@@ -258,7 +259,7 @@ async function _initProvidersUnsynchronized(config, signal = null) {
258
259
  // Second guard: every entry was disabled. Same reasoning — keep the
259
260
  // existing registry rather than going dark.
260
261
  if (next.size === 0) {
261
- throw new Error('[provider] all providers disabled in config — refusing to clear registry');
262
+ throw noEnabledProvidersError('[provider] all providers disabled in config — refusing to clear registry');
262
263
  }
263
264
  // OAuth preservation guard. anthropic-oauth / openai-oauth are NOT stored
264
265
  // in mixdog-config.json — buildDefaultConfig injects them at runtime by
@@ -6,11 +6,13 @@ import { clean } from '../../../shared/clean.mjs';
6
6
  // dropped on normalize rather than rejected.
7
7
  const TASK_ENTRY_FIELDS = ['id', 'text', 'status', 'kind'];
8
8
 
9
- // Dropped rows remain in the record but are no longer requested work.
9
+ // Dropped rows remain in the record but are no longer requested work. Every
10
+ // row, completed and dropped included, still fills one of the capped slots.
10
11
  export function goalTaskProgress(tasks) {
11
12
  return {
12
13
  tasksCompleted: tasks.filter((task) => task?.status === 'completed').length,
13
14
  tasksTotal: tasks.filter((task) => task?.status !== 'dropped').length,
15
+ taskSlotsUsed: tasks.length,
14
16
  };
15
17
  }
16
18
 
@@ -89,12 +91,18 @@ function taskInputRetains(entry, task) {
89
91
  }
90
92
 
91
93
  export function patchGoalTasks(previous, { updates, tasks } = {}) {
92
- const patches = updates ?? [];
93
- const additions = tasks ?? [];
94
- if (!Array.isArray(patches) || !Array.isArray(additions)) throw new Error('goal updates and tasks must be arrays');
94
+ const requested = updates ?? [];
95
+ const entries = tasks ?? [];
96
+ if (!Array.isArray(requested) || !Array.isArray(entries)) throw new Error('goal updates and tasks must be arrays');
97
+ const byId = new Map(previous.map((task) => [task.id, { ...task }]));
98
+ // A new-task entry naming an id this Goal already has can only mean that
99
+ // task, so it is applied as the task's update. An id the Goal does not have
100
+ // is still refused below.
101
+ const addressesExisting = (entry) => byId.has(clean(entry?.id));
102
+ const patches = [...requested, ...entries.filter(addressesExisting)];
103
+ const additions = entries.filter((entry) => !addressesExisting(entry));
95
104
  if (!patches.length && !additions.length) throw new Error('goal update_tasks requires updates or new tasks');
96
105
  if (patches.length > MAX_GOAL_TASKS) throw new Error(`goal updates support at most ${MAX_GOAL_TASKS} entries`);
97
- const byId = new Map(previous.map((task) => [task.id, { ...task }]));
98
106
  const seen = new Set();
99
107
  for (const patch of patches) {
100
108
  const id = clean(patch?.id);
@@ -3,6 +3,7 @@
3
3
  // No filesystem, no runtime coupling — values in, text out.
4
4
 
5
5
  import { goalTaskProgress } from './goal-tasks.mjs';
6
+ import { MAX_GOAL_TASKS } from './goal-tool-defs.mjs';
6
7
 
7
8
  function escapeGoalPromptText(value) {
8
9
  return String(value ?? '')
@@ -214,7 +215,7 @@ export function goalStateReminder(goal, { reason = '' } = {}) {
214
215
  }
215
216
  if (reason === 'deadline-reached' && goal.status === 'active') return '';
216
217
  const tasks = Array.isArray(goal.tasks) ? goal.tasks : [];
217
- const { tasksCompleted, tasksTotal } = goalTaskProgress(tasks);
218
+ const { tasksCompleted, tasksTotal, taskSlotsUsed } = goalTaskProgress(tasks);
218
219
  // Event-specific steering: what the model could not have learned from its own
219
220
  // tool results. Standing rules stay in the cached tool description.
220
221
  let lead = 'Current durable Goal snapshot:';
@@ -233,7 +234,7 @@ export function goalStateReminder(goal, { reason = '' } = {}) {
233
234
  ...(goal.status === 'blocked' ? [BLOCKED_LINE] : []),
234
235
  '',
235
236
  `Objective: ${escapeGoalPromptText(goal.objective)}`,
236
- `Status: ${escapeGoalPromptText(goal.status)} · tasks ${tasksCompleted}/${tasksTotal}`,
237
+ `Status: ${escapeGoalPromptText(goal.status)} · tasks ${tasksCompleted}/${tasksTotal} · task slots used ${taskSlotsUsed}/${MAX_GOAL_TASKS}`,
237
238
  ...(goal.blocker ? [`Waiting or stop reason: ${escapeGoalPromptText(goal.blocker)}`] : []),
238
239
  ...(goal.revision ? [`Revision: ${goal.revision}`] : []),
239
240
  ...goalTimeLines(goal),
@@ -16,7 +16,7 @@ const taskFields = {
16
16
  type: 'string',
17
17
  minLength: 1,
18
18
  maxLength: MAX_GOAL_TASK_TEXT_LENGTH,
19
- description: 'Required work or verification outcome.',
19
+ description: 'Brief work/check; results go in reply.',
20
20
  },
21
21
  status: {
22
22
  type: 'string',
@@ -22,6 +22,7 @@ function toolReply(ctx, id, goal, { full = false, previousIds = null } = {}) {
22
22
  status: goal.status,
23
23
  tasksCompleted: goal.tasksCompleted,
24
24
  tasksTotal: goal.tasksTotal,
25
+ taskSlotsUsed: goal.taskSlotsUsed,
25
26
  tasksUpdatedAt: goal.tasksUpdatedAt,
26
27
  timeUsedMs: goal.timeUsedMs,
27
28
  };
@@ -86,7 +86,7 @@ export function composeSessionSystem(opts, { profile, providerName, modelName, s
86
86
  const languageContext = leadOnly ? _buildLeadLanguageContext() : '';
87
87
  const shellEnvironmentContext = buildShellEnvironmentContext(opts, ownerIsAgent, surface.toolsForRouting);
88
88
  // Persisted env tail: refreshSessionBp3Environment rebuilds the env block
89
- // as [session, project, bp3EnvironmentContext], so the language block must
89
+ // as [session, bp3EnvironmentContext], so the language block must
90
90
  // already sit at the end of this string to stay last after a refresh.
91
91
  const environmentTailContext = [shellEnvironmentContext, languageContext].filter(Boolean).join('\n\n---\n\n');
92
92
  const composed = composeSystemPrompt({
@@ -174,6 +174,23 @@ function hasMultipleAbsoluteWindowsPaths(value) {
174
174
  return Array.isArray(matches) && matches.length > 1;
175
175
  }
176
176
 
177
+ // A string that only lists absolute Windows paths ("C:\a C:\b", "C:\a,C:\b",
178
+ // "[C:/a, C:/b]", '["C:/a", "D:/b"]') splits exactly: every piece starts at a
179
+ // drive root, and no drive root can occur inside a Windows path, so spaces
180
+ // within a path stay put. Any other text around the paths returns null.
181
+ function splitListedAbsoluteWindowsPaths(value) {
182
+ const body = value
183
+ .trim()
184
+ .replace(/^\[([\s\S]*)\]$/, '$1')
185
+ .replace(/^[\s,;]+|[\s,;]+$/g, '');
186
+ const pieces = body
187
+ .split(/[\s,;]+(?=["']?[A-Za-z]:[\\/])/)
188
+ .map((piece) => piece.replace(/^(["'])(.*)\1$/, '$2'));
189
+ return pieces.every((piece) => /^[A-Za-z]:[\\/]/.test(piece) && !/[A-Za-z]:[\\/]/.test(piece.slice(3)))
190
+ ? pieces
191
+ : null;
192
+ }
193
+
177
194
  function isFiniteInt(v) {
178
195
  return typeof v === 'number' && Number.isFinite(v) && Math.floor(v) === v;
179
196
  }
@@ -418,8 +435,17 @@ function guardGrep(a) {
418
435
  return `Error: grep arg "${k}" must be string (got ${describeType(a[k])})`;
419
436
  }
420
437
  }
421
- // Independent explicit scopes share one request; no inferred path splitting.
438
+ // Independent explicit scopes share one request. A string that only lists
439
+ // absolute paths is those scopes; any other string holding several drive
440
+ // roots is refused rather than guessed at.
422
441
  for (const k of ['path', 'root']) {
442
+ if (hasOwn(a, k) && hasMultipleAbsoluteWindowsPaths(a[k])) {
443
+ const listed = splitListedAbsoluteWindowsPaths(a[k]);
444
+ if (!listed) {
445
+ return `Error: grep arg "${k}" contains multiple absolute paths in one string. Use one common parent path plus glob, or separate grep calls.`;
446
+ }
447
+ a[k] = listed;
448
+ }
423
449
  if (hasOwn(a, k) && Array.isArray(a[k])) {
424
450
  if (a[k].length === 0 || a[k].length > PUBLIC_PATH_BATCH_LIMIT || !a[k].every(isNonEmptyString)) {
425
451
  return `Error: grep arg "${k}" must contain 1-${PUBLIC_PATH_BATCH_LIMIT} non-empty path strings`;
@@ -429,9 +455,6 @@ function guardGrep(a) {
429
455
  if (hasOwn(a, k) && !isString(a[k])) {
430
456
  return `Error: grep arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
431
457
  }
432
- if (hasOwn(a, k) && hasMultipleAbsoluteWindowsPaths(a[k])) {
433
- return `Error: grep arg "${k}" contains multiple absolute paths in one string. Use one common parent path plus glob, or separate grep calls.`;
434
- }
435
458
  }
436
459
  for (const k of ['head_limit', 'offset']) {
437
460
  const err = checkIntInRange(a, k, 0, MAX_INT);
@@ -33,7 +33,9 @@ export function sliceReadBodyByLines(body, origOffset, origLimit, readOffsetBase
33
33
  if (ln < minSeen) minSeen = ln;
34
34
  if (ln > maxSeen) maxSeen = ln;
35
35
  if (ln >= firstLine && ln <= lastLine) kept.push(line);
36
- } else if (kept.length === 0 && footerIdx === -1) {
36
+ } else if (kept.length === 0 && footerIdx === -1 && !Number.isFinite(maxSeen)) {
37
+ // Only a preamble before the first numbered row; a blank line or a
38
+ // truncation marker after the rows is not this slot's content.
37
39
  kept.push(line);
38
40
  }
39
41
  }
@@ -42,7 +42,8 @@ async function estimateTaskBytes(entry, workDir) {
42
42
  if (!st?.isFile()) return Infinity;
43
43
  const lines = Math.max(1, st.size / AVG_LINE_BYTES);
44
44
  let need = st.size + st.size / MIN_LINE_BYTES * LINE_PREFIX_BYTES;
45
- const limit = Number(entry?.limit);
45
+ // A coalesced entry reads its whole union window, not only its own slot.
46
+ const limit = Number(readEntryCoalescedDiskWindow(entry)?.limit ?? entry?.limit);
46
47
  if (Number.isFinite(limit) && limit > 0) need *= Math.min(1, limit / lines);
47
48
  return Math.ceil(need) + READ_RENDER_SLACK_BYTES;
48
49
  }
@@ -100,9 +100,12 @@ function _throwIfAborted(signal) {
100
100
  if (signal?.aborted) throw new Error('aborted');
101
101
  }
102
102
 
103
- function codeGraphBuildInvalidatedError() {
103
+ // `graph` is the build that finished anyway: the tree as of its own manifest,
104
+ // never cached as current. The last retry answers from it instead of failing.
105
+ function codeGraphBuildInvalidatedError(graph = null) {
104
106
  const error = new Error('code-graph build invalidated during prewarm');
105
107
  error.code = 'ERR_CODE_GRAPH_BUILD_INVALIDATED';
108
+ if (graph) error.graph = graph;
106
109
  return error;
107
110
  }
108
111
 
@@ -115,7 +118,12 @@ export async function _retryCodeGraphBuildAfterInvalidation(run, { signal = null
115
118
  try {
116
119
  return await run(attempt);
117
120
  } catch (error) {
118
- if (signal?.aborted || !isCodeGraphBuildInvalidatedError(error) || attempt === 1) {
121
+ if (signal?.aborted || !isCodeGraphBuildInvalidatedError(error)) throw error;
122
+ // Files kept changing through the retry too (another session editing
123
+ // the same tree). The finished graph is a consistent snapshot a few
124
+ // seconds old; answer this query from it rather than fail it.
125
+ if (attempt === 1) {
126
+ if (error.graph) return error.graph;
119
127
  throw error;
120
128
  }
121
129
  }
@@ -159,7 +167,7 @@ async function _validateDiskCodeGraphHit({
159
167
  if (diskEntry.signature !== signature) return { graph: null, manifest, signature };
160
168
  const graph = deserializeGraph(graphCwd, diskEntry);
161
169
  if (!graph) return { graph: null, manifest, signature };
162
- if (getGeneration(graphCwd) !== genAtStart) return { invalidated: true };
170
+ if (getGeneration(graphCwd) !== genAtStart) return { invalidated: true, graph };
163
171
  setMemoryCache(graphCwd, { ts: now, signature, graph });
164
172
  return { graph, manifest, signature };
165
173
  }
@@ -195,7 +203,7 @@ export async function _runDiskCodeGraphFastPath({
195
203
  _throwIfAborted(signal);
196
204
  const hit = await validateDiskHit({ graphCwd, diskEntry, genAtStart, signal });
197
205
  _throwIfAborted(signal);
198
- if (hit?.invalidated) throw codeGraphBuildInvalidatedError();
206
+ if (hit?.invalidated) throw codeGraphBuildInvalidatedError(hit.graph);
199
207
  if (hit?.graph) return hit.graph;
200
208
  _throwIfAborted(signal);
201
209
  const workerPromise = spawnWorker(release, hit?.manifest || null, hit?.signature || null);
@@ -463,7 +471,7 @@ export function _spawnCodeGraphWorker(
463
471
  // manifest lock.
464
472
  setDiskCache(graphCwd, msg.graph, { persist: false });
465
473
  }
466
- settle(genStillCurrent ? msg.graph : codeGraphBuildInvalidatedError());
474
+ settle(genStillCurrent ? msg.graph : codeGraphBuildInvalidatedError(msg.graph));
467
475
  } else {
468
476
  settle(_codeGraphWorkerFailure(msg));
469
477
  }
@@ -79,7 +79,7 @@ function recoveryForCode(code, args) {
79
79
  code,
80
80
  next: 'wait_for_user',
81
81
  guidance:
82
- 'Computer Use yielded to the user. Call wait_for_user for bounded waiting. Ordinary physical input may resume after the host-configured quiet interval (default 5 seconds); explicit stops and uncertain cleanup/observation require the user. Timeout does not authorize input. After resumed, capture fresh state; never replay interrupted input. Manual Resume is also available on the overlay.',
82
+ "Computer Use yielded to the user. Call wait_for_user for bounded waiting. The user's Resume on the overlay continues the pause, and ordinary physical input may also resume after the host-configured quiet interval (default 5 seconds); explicit stops and uncertain cleanup/observation require the user. Stop cancels the task. Timeout does not authorize input. After resumed, capture fresh state; never replay interrupted input.",
83
83
  };
84
84
  }
85
85
  if (DIAGNOSE_CODES.has(code)) {
@@ -25,6 +25,8 @@ function applyField(current, path, change) {
25
25
  const next = isPlainObject(current) ? { ...current } : {};
26
26
  const [key, ...rest] = path;
27
27
  if (change.remove && rest.length && !Object.hasOwn(next, key)) return next;
28
+ // Seed-only field: written where it is missing, never over a stored value.
29
+ if (change.ifAbsent && !rest.length && Object.hasOwn(next, key)) return next;
28
30
  if (!rest.length && change.remove) {
29
31
  delete next[key];
30
32
  } else {
@@ -28,7 +28,7 @@ function getBackupRoot() {
28
28
  return process.env.MIXDOG_USER_DATA_BACKUP_ROOT || join(mixdogConfigBaseDir(), 'backups', 'user-data');
29
29
  }
30
30
 
31
- const USER_DATA_FILES = ['mixdog-config.json', 'instructions.md', 'user-workflow.md'];
31
+ const USER_DATA_FILES = ['mixdog-config.json'];
32
32
 
33
33
  const USER_DATA_DIRS = ['schedules', 'webhooks', 'workflows'];
34
34
 
@@ -102,10 +102,20 @@ export function createConfigLifecycle({
102
102
  const adopted = adoptConfig(nextConfig, { hasSecrets });
103
103
  const changes = cfgMod.createConfigPatch(configSaveBaseline, adopted);
104
104
  configSaveBaseline = structuredClone(adopted);
105
- if (changes.length) writers.queueConfigChanges(changes);
105
+ if (changes.length) writers.queueConfigChanges([...changes, ...seedChanges(adopted, changes)]);
106
106
  return adopted;
107
107
  }
108
108
 
109
+ // A fresh profile's install markers (an empty `builtins`) and off-by-default
110
+ // agents equal the defaults, so no diff ever carries them — yet on disk
111
+ // their absence reads as a grandfathered profile with every agent on. The
112
+ // first write seeds them where the stored section lacks them.
113
+ function seedChanges(config, changes) {
114
+ return ['builtins', 'disabledAgents']
115
+ .filter((key) => config[key] !== undefined && !changes.some((change) => change.path[0] === key))
116
+ .map((key) => ({ path: [key], value: structuredClone(config[key]), ifAbsent: true }));
117
+ }
118
+
109
119
  function scheduleSkillsSave(names) {
110
120
  // This field belongs to its dedicated writer, not a later unrelated save.
111
121
  configSaveBaseline.skills = structuredClone(getConfig().skills);
@@ -24,7 +24,7 @@ export function createCoreMemoryContext({ getCurrentCwd, getConfig, bootProfile,
24
24
  async function loadCoreMemoryContext() {
25
25
  // User-curated core memory injects into new sessions by default. The
26
26
  // Memory toggle (settings → General) OFF skips ONLY this accumulated
27
- // core-memory block — profile and Project Instructions inject regardless.
27
+ // core-memory block — the profile injects regardless.
28
28
  if (!(featureEnvOverride('MIXDOG_FEATURE_MEMORY') ?? memoryToolsEnabled(getConfig(), true))) {
29
29
  bootProfile('core-memory:disabled');
30
30
  return '';
@@ -1,6 +1,7 @@
1
1
  // cwd-plugins/plugin-status.mjs — the registered-plugin status list with its
2
2
  // per-root caches (manifest keyed on mtime, MCP discovery and skill-file count
3
3
  // on a short TTL).
4
+ import { readFileSync } from 'node:fs';
4
5
  import { discoverPluginMcp } from '../../runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs';
5
6
  import { pluginMetadata } from '../../runtime/shared/plugin-metadata.mjs';
6
7
 
@@ -35,11 +36,25 @@ export function createPluginStatus({
35
36
  }
36
37
  return key;
37
38
  }
39
+ // A manifest file that exists but is not a JSON object is breakage.
40
+ function manifestInvalid(root) {
41
+ for (const rel of ['.codex-plugin/plugin.json', 'plugin.json']) {
42
+ const file = resolve(root, rel);
43
+ if (!existsSync(file)) continue;
44
+ try {
45
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return true;
47
+ } catch {
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
38
53
  function cachedPluginData(root) {
39
54
  const key = manifestMtimeKey(root);
40
55
  const hit = pluginRootCache.get(root);
41
56
  if (hit && hit.key === key) return hit;
42
- const entry = { key, manifest: pluginManifest(root) };
57
+ const entry = { key, manifest: pluginManifest(root), invalid: manifestInvalid(root) };
43
58
  pluginRootCache.set(root, entry);
44
59
  return entry;
45
60
  }
@@ -65,8 +80,9 @@ export function createPluginStatus({
65
80
 
66
81
  function registeredPlugin(entry, configuredMcp) {
67
82
  const root = clean(entry.root);
68
- if (!root || !existsSync(root)) return null;
69
- const manifest = cachedPluginData(root).manifest;
83
+ if (!root) return null;
84
+ if (!existsSync(root)) return { missing: true, id: clean(entry.id) || clean(entry.name) || root, name: clean(entry.name) || clean(entry.id) || root.split(/[\\/]/).pop() || root };
85
+ const { manifest, invalid } = cachedPluginData(root);
70
86
  const name = clean(manifest.name) || clean(manifest.id) || clean(entry.name) || root.split(/[\\/]/).pop() || root;
71
87
  const { mcpScript, mcpInline } = cachedMcpDiscovery(root);
72
88
  const plugin = {
@@ -89,6 +105,7 @@ export function createPluginStatus({
89
105
  mcpScript,
90
106
  mcpInline,
91
107
  };
108
+ if (invalid) plugin.invalid = true;
92
109
  plugin.mcpServerName = pluginMcpServerName(plugin);
93
110
  plugin.mcpEnabled =
94
111
  Object.hasOwn(configuredMcp, plugin.mcpServerName) ||
@@ -101,9 +118,11 @@ export function createPluginStatus({
101
118
  const dataDir = cfgMod.getPluginData?.();
102
119
  const configuredMcp = config?.mcpServers && typeof config.mcpServers === 'object' ? config.mcpServers : {};
103
120
  const plugins = [];
121
+ const missing = [];
104
122
  for (const entry of listRegisteredPlugins({ dataDir })) {
105
123
  const plugin = registeredPlugin(entry, configuredMcp);
106
- if (plugin) plugins.push(plugin);
124
+ if (plugin?.missing) missing.push({ id: plugin.id, name: plugin.name, enabled: entry.enabled !== false });
125
+ else if (plugin) plugins.push(plugin);
107
126
  }
108
127
  plugins.sort((a, b) => {
109
128
  if (a.source !== b.source) return a.source.localeCompare(b.source);
@@ -113,6 +132,7 @@ export function createPluginStatus({
113
132
  return {
114
133
  count: plugins.length,
115
134
  plugins,
135
+ missing,
116
136
  roots: {
117
137
  registry: admin.registryPath,
118
138
  installed: admin.installRoot,