mixdog 1.3.0 → 1.3.1

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 (103) hide show
  1. package/NOTICE.md +52 -9
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -1
  4. package/src/defaults/skills/setup/references/actions.md +4 -1
  5. package/src/defaults/skills/setup-guide/SKILL.md +10 -7
  6. package/src/output-styles/common.md +13 -8
  7. package/src/rules/agent-mode/AGENT_MODE.md +1 -1
  8. package/src/rules/agent-mode/MIXY.md +11 -4
  9. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +72 -14
  10. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +7 -2
  12. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +7 -8
  13. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +3 -1
  14. package/src/runtime/agent/orchestrator/providers/lib/tool-call-wire-ids.mjs +65 -0
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +8 -2
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +9 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +3 -1
  18. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +14 -2
  19. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +17 -0
  20. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +4 -2
  21. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +37 -7
  23. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +5 -3
  24. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +4 -0
  25. package/src/runtime/agent/orchestrator/session/agent-room.mjs +17 -8
  26. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +32 -5
  27. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +9 -0
  28. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +108 -6
  29. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +8 -14
  30. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +46 -4
  31. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +18 -8
  32. package/src/runtime/agent/orchestrator/session/manager/provider-replay.mjs +50 -0
  33. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -20
  34. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +3 -0
  35. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +7 -1
  36. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +5 -2
  38. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +17 -1
  39. package/src/runtime/agent/orchestrator/session/routine-result.mjs +61 -9
  40. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +14 -9
  41. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +92 -15
  42. package/src/runtime/attachments/store.mjs +6 -0
  43. package/src/runtime/effort-judge/judge-client.mjs +1 -1
  44. package/src/runtime/effort-judge/model-manifest.json +7 -7
  45. package/src/runtime/shared/agent-kind.mjs +152 -0
  46. package/src/runtime/shared/agent-route-config.d.mts +1 -0
  47. package/src/runtime/shared/agent-route-config.mjs +22 -8
  48. package/src/runtime/shared/agent-store.mjs +132 -0
  49. package/src/runtime/shared/bot-split-migration.mjs +457 -0
  50. package/src/runtime/shared/config-patch.mjs +20 -1
  51. package/src/runtime/shared/connector-catalog.d.mts +1 -2
  52. package/src/runtime/shared/connector-catalog.mjs +2 -4
  53. package/src/runtime/shared/connector-triggers.mjs +2 -0
  54. package/src/runtime/shared/err-text.mjs +3 -1
  55. package/src/runtime/shared/error-presentation.mjs +91 -6
  56. package/src/runtime/shared/handoff-artifacts.d.mts +20 -0
  57. package/src/runtime/shared/handoff-artifacts.mjs +61 -0
  58. package/src/runtime/shared/llm/provider-model-identities.mjs +6 -1
  59. package/src/runtime/shared/llm/usage-accounting.mjs +7 -1
  60. package/src/runtime/shared/model-route-fallback.mjs +79 -7
  61. package/src/runtime/shared/plain-preview.mjs +22 -17
  62. package/src/runtime/shared/sensitive-path-rules.d.mts +15 -0
  63. package/src/runtime/shared/sensitive-path-rules.mjs +256 -0
  64. package/src/runtime/shared/usage-readout-totals.mjs +15 -13
  65. package/src/runtime/shared/user-data-guard.mjs +5 -2
  66. package/src/session-runtime/agent-home-delivery.mjs +423 -56
  67. package/src/session-runtime/agent-room-delivery.mjs +45 -4
  68. package/src/session-runtime/agent-task-sessions.mjs +299 -39
  69. package/src/session-runtime/boot/apis.mjs +13 -0
  70. package/src/session-runtime/boot/providers.mjs +14 -4
  71. package/src/session-runtime/config-lifecycle.mjs +43 -2
  72. package/src/session-runtime/handoff-attachments.mjs +593 -0
  73. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +27 -3
  74. package/src/session-runtime/model-route/route-persist.mjs +2 -2
  75. package/src/session-runtime/model-route/route-tuning.mjs +7 -1
  76. package/src/session-runtime/model-route/set-route.mjs +21 -1
  77. package/src/session-runtime/native-web-search.mjs +2 -2
  78. package/src/session-runtime/new-session-config.mjs +6 -1
  79. package/src/session-runtime/route-resolution.mjs +5 -11
  80. package/src/session-runtime/services/agent-tool/helpers.mjs +24 -5
  81. package/src/session-runtime/services/agent-tool/send-flow.mjs +11 -3
  82. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
  83. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +5 -8
  84. package/src/session-runtime/services/agent-tool/tool-def.mjs +7 -0
  85. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  86. package/src/session-runtime/setup-tool/extended-actions.mjs +19 -7
  87. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -0
  88. package/src/session-runtime/tool-policy-refresh.mjs +13 -2
  89. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +11 -0
  90. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +123 -32
  91. package/src/session-runtime/workflow-agents-api/agent-route.mjs +67 -7
  92. package/src/session-runtime/workflow-agents-api/onboarding.mjs +11 -4
  93. package/src/standalone/daemon-session-control.mjs +13 -5
  94. package/src/standalone/daemon.mjs +16 -4
  95. package/src/standalone/session-service/agent-tree/agent-turns.mjs +46 -3
  96. package/src/standalone/session-service/agent-tree.mjs +4 -3
  97. package/src/standalone/session-service/session-calls/session-turns.mjs +19 -0
  98. package/src/tui/dist/index.mjs +70 -9
  99. package/src/tui/session/notice-surface.mjs +3 -4
  100. package/src/tui/session/session-api/routes.mjs +14 -3
  101. package/src/tui/session/transcript-intake.mjs +2 -0
  102. package/src/tui/session/turn-live-call.mjs +11 -3
  103. package/src/tui/session/turn.mjs +13 -3
@@ -0,0 +1,457 @@
1
+ // The one-time 1.3.1 split of bots out of agents/: every definition the
2
+ // read-only rule (agent-kind.mjs) classifies as a bot is COPIED from
3
+ // agents/<id>/ to bots/<id>/ with its own model route, and Mixy's identity
4
+ // from agents/lead/ to bots/mixy/. Nothing under agents/ and no session file
5
+ // is ever moved, rewritten or deleted: the originals stay for rollback and
6
+ // the manifest (bots/migration.json) tells the agent catalog which agents/
7
+ // ids are bots now. Home threads keep their ids — a bot's is still
8
+ // sess_home_<id>, Mixy's still sess_home_lead (MIXY_LEGACY_HOME_SESSION_ID) —
9
+ // so every transcript stays whole and continuable.
10
+ //
11
+ // Every later start re-syncs (migrateBotsToOwnStore): it adds nothing and
12
+ // rewrites nothing unless agents/ gained or changed a bot since (a downgrade
13
+ // to 1.3.0 and back). The re-sync never overwrites a bots/ edit: a copy is
14
+ // replaced only when its fingerprint still equals the one recorded when it
15
+ // was made (bots/ untouched since) AND its agents/ original's fingerprint
16
+ // differs and has newer files; when both sides changed, both are kept (the
17
+ // bots/ copy in use, the 1.3.0 edit left in agents/, logged). Every copy or
18
+ // replacement is preceded by a user-data backup that holds agents/ and bots/.
19
+ //
20
+ // One process migrates at a time (an exclusive lock file beside the profile,
21
+ // reclaimed when its owner died); another starting meanwhile waits for it,
22
+ // or after LOCK_WAIT_MS reads the profile as it is this start. Publishing
23
+ // the manifest is the commit point: a failure before it undoes only this
24
+ // run's own copies and leaves the profile as it was (the next start tries
25
+ // again); nothing after it is undone. A replaced bots/<id> is set aside as
26
+ // `.<id>.<run>.old` until then; the manifest names the committed ones
27
+ // (`retired`), which any later start removes, while one it does not name is
28
+ // an interrupted run's and is put back.
29
+ import { createHash } from 'node:crypto';
30
+ import {
31
+ cpSync,
32
+ existsSync,
33
+ mkdirSync,
34
+ readdirSync,
35
+ readFileSync,
36
+ renameSync,
37
+ rmSync,
38
+ statSync,
39
+ writeFileSync,
40
+ } from 'node:fs';
41
+ import { basename, dirname, join, relative } from 'node:path';
42
+ import { withFileLockSync, writeJsonAtomicSync } from './atomic-file.mjs';
43
+ import { evidenceKind, homeThreadEvidence } from './agent-kind.mjs';
44
+ import { BOT_AGENT_KIND } from './agent-route-config.mjs';
45
+ import { readMarkdownDocument } from './markdown-frontmatter.mjs';
46
+ import {
47
+ BOT_MIGRATION_MANIFEST,
48
+ BOT_MIGRATION_VERSION,
49
+ BOTS_DIR,
50
+ MIXY_BOT_ID,
51
+ migratedBotIds,
52
+ readBotMigration,
53
+ } from './agent-store.mjs';
54
+
55
+ export { BOT_MIGRATION_MANIFEST, BOT_MIGRATION_VERSION, BOTS_DIR, MIXY_BOT_ID, migratedBotIds, readBotMigration };
56
+ /** Mixy's home thread keeps the id it always had: its messages never move. */
57
+ export const MIXY_LEGACY_HOME_SESSION_ID = 'sess_home_lead';
58
+
59
+ /** The cross-process migration lock, beside (never inside) bots/ and agents/. */
60
+ export const BOT_MIGRATION_LOCK = '.bot-split.lock';
61
+ const LOCK_WAIT_MS = 10_000;
62
+ /** What the split's backup carries besides the routine user data: every
63
+ * definition it copies from and every copy a re-sync can replace. */
64
+ export const BOT_SPLIT_BACKUP_DIRS = Object.freeze(['agents', BOTS_DIR]);
65
+
66
+ const DELETED_MARKER = '.deleted';
67
+ const ARCHIVED_FILE = 'AGENT.md.archived';
68
+ // Mixy's own files under agents/lead/ (its identity, persona, 일하는 방식, face).
69
+ const MIXY_IDENTITY_KEYS = ['name', 'description', 'avatar', 'connectors', 'created'];
70
+
71
+ function readJson(file) {
72
+ try {
73
+ const value = JSON.parse(readFileSync(file, 'utf8'));
74
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeJson(file, value) {
81
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
82
+ }
83
+
84
+ // The definition file a bot's kind is read from: AGENT.md, or the archived
85
+ // copy of a deleted bot (its chat is kept, so it moves with the others).
86
+ function definitionFile(dir) {
87
+ if (existsSync(join(dir, 'AGENT.md'))) return join(dir, 'AGENT.md');
88
+ if (existsSync(join(dir, DELETED_MARKER)) && existsSync(join(dir, ARCHIVED_FILE))) return join(dir, ARCHIVED_FILE);
89
+ return '';
90
+ }
91
+
92
+ /** The agents/ ids the rule classifies as bots (Mixy's `lead` never). */
93
+ export function classifyBotIds({ dataDir, shippedRoot }) {
94
+ const root = join(dataDir, 'agents');
95
+ let entries = [];
96
+ try {
97
+ entries = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory());
98
+ } catch {
99
+ return [];
100
+ }
101
+ const bots = [];
102
+ for (const { name: id } of entries) {
103
+ if (id === 'lead') continue;
104
+ const dir = join(root, id);
105
+ const file = definitionFile(dir);
106
+ if (!file) continue;
107
+ const doc = readMarkdownDocument(readFileSync(file, 'utf8'));
108
+ // The evidence rule itself, whatever the store says (a re-sync runs it
109
+ // with the store active).
110
+ const kind = evidenceKind({
111
+ frontmatterKind: doc.frontmatter.kind,
112
+ dataDir,
113
+ shippedRoot,
114
+ id,
115
+ manifest: readJson(join(dir, 'agent.json')) || {},
116
+ body: doc.body,
117
+ });
118
+ if (kind === BOT_AGENT_KIND) bots.push(id);
119
+ }
120
+ return bots.sort();
121
+ }
122
+
123
+ /** Whether agents/lead holds a Mixy the user made or used: a name, face,
124
+ * connectors, persona or 일하는 방식, or any conversation in its thread —
125
+ * never v1.3.0's untouched automatic `{"created": true}`. */
126
+ export function mixyToMigrate(dataDir) {
127
+ const dir = join(dataDir, 'agents', 'lead');
128
+ const manifest = readJson(join(dir, 'agent.json')) || {};
129
+ if (manifest.created !== true) return false;
130
+ const identity =
131
+ Boolean(String(manifest.name ?? '').trim()) ||
132
+ manifest.avatar != null ||
133
+ manifest.connectors != null ||
134
+ existsSync(join(dir, 'PERSONA.md')) ||
135
+ existsSync(join(dir, 'AGENT.md'));
136
+ return identity || homeThreadEvidence(dataDir, 'lead').chatted;
137
+ }
138
+
139
+ // The provider/model Mixy's thread runs on, or null.
140
+ function threadRoute(dataDir, sessionId) {
141
+ const record = readJson(join(dataDir, 'sessions', `${sessionId}.json`));
142
+ const provider = String(record?.provider || '').trim();
143
+ const model = String(record?.model || '').trim();
144
+ return provider && model ? { provider, model } : null;
145
+ }
146
+
147
+ const completeRoute = (route) =>
148
+ route && typeof route === 'object' && String(route.provider || '').trim() && String(route.model || '').trim()
149
+ ? route
150
+ : null;
151
+
152
+ // --- Content fingerprints (for the re-sync rule) ---
153
+ // A folder's files as one hash; agent.json without the keys only the store
154
+ // writes (a bot's route, Mixy's thread id and created flag), so changing a
155
+ // bot's model on 1.3.1 never counts as editing its definition.
156
+ const STORE_ONLY_KEYS = ['route', 'homeSessionId', 'created'];
157
+
158
+ function folderFiles(dir) {
159
+ try {
160
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
161
+ .filter((entry) => entry.isFile())
162
+ .map((entry) => join(entry.parentPath ?? entry.path, entry.name))
163
+ .filter((file) => !file.endsWith('.lock'))
164
+ .sort();
165
+ } catch {
166
+ return [];
167
+ }
168
+ }
169
+
170
+ // A JSON value with every object's keys sorted, nested ones included (a
171
+ // replacer array would drop every nested key not in the top-level list).
172
+ function canonical(value) {
173
+ if (Array.isArray(value)) return value.map(canonical);
174
+ if (!value || typeof value !== 'object') return value;
175
+ return Object.fromEntries(
176
+ Object.keys(value)
177
+ .sort()
178
+ .map((key) => [key, canonical(value[key])])
179
+ );
180
+ }
181
+
182
+ function folderHash(dir) {
183
+ const hash = createHash('sha256');
184
+ for (const file of folderFiles(dir)) {
185
+ let bytes = readFileSync(file);
186
+ const manifest = basename(file) === 'agent.json' && dirname(file) === dir ? readJson(file) : null;
187
+ if (manifest) {
188
+ for (const key of STORE_ONLY_KEYS) delete manifest[key];
189
+ bytes = Buffer.from(JSON.stringify(canonical(manifest)));
190
+ }
191
+ hash
192
+ .update(`${relative(dir, file)}\0`)
193
+ .update(bytes)
194
+ .update('\0');
195
+ }
196
+ return hash.digest('hex').slice(0, 24);
197
+ }
198
+
199
+ function newestChange(dir) {
200
+ let newest = 0;
201
+ for (const file of folderFiles(dir)) newest = Math.max(newest, statSync(file).mtimeMs);
202
+ return newest;
203
+ }
204
+
205
+ // A staging or set-aside folder an earlier run left (`.<id>.<pid>-<time>.<kind>`).
206
+ // Swept under the migration lock, so no run that made one is still going.
207
+ const LEFTOVER = /^\.(.+)\.(\d+)-\d+\.(staging|old)$/;
208
+ const removeBestEffort = (path) => {
209
+ try {
210
+ rmSync(path, { recursive: true, force: true });
211
+ } catch {
212
+ // a later start removes it
213
+ }
214
+ };
215
+ // A staging folder goes; a set-aside `.old` the manifest names as retired is
216
+ // a committed swap's rest and goes; any other `.old` is the bot as it was
217
+ // before a run that never committed: it is put back (a failure here fails
218
+ // this run, which then leaves the profile as it is).
219
+ function sweepLeftovers(botsRoot, retired) {
220
+ for (const name of existsSync(botsRoot) ? readdirSync(botsRoot) : []) {
221
+ const match = LEFTOVER.exec(name);
222
+ if (!match) continue;
223
+ const path = join(botsRoot, name);
224
+ if (match[3] === 'staging' || retired.has(name)) {
225
+ removeBestEffort(path);
226
+ continue;
227
+ }
228
+ const target = join(botsRoot, match[1]);
229
+ if (existsSync(target)) {
230
+ const aside = join(botsRoot, `.${match[1]}.${match[2]}-0.staging`);
231
+ renameSync(target, aside);
232
+ removeBestEffort(aside);
233
+ }
234
+ renameSync(path, target);
235
+ }
236
+ }
237
+
238
+ // The finished split's record as on disk now (never a cached read: another
239
+ // process may have just written it).
240
+ function readManifestNow(botsRoot) {
241
+ const manifest = readJson(join(botsRoot, BOT_MIGRATION_MANIFEST));
242
+ return manifest?.version === BOT_MIGRATION_VERSION ? manifest : null;
243
+ }
244
+
245
+ /**
246
+ * Runs the split, and on every later start the re-sync (`backup()` first
247
+ * whenever something is to be copied). `routeOf(id)` is the bot's route in
248
+ * the config (null when none); `leadRoute` the Lead's route at this time
249
+ * (Mixy's route when its thread names none).
250
+ *
251
+ * First run: every bot in agents/ is copied to bots/<id> (a bots/<id>
252
+ * already there — a concurrent run's, a hand-made or restored folder — is
253
+ * kept as it is and adopted), and a used Mixy to bots/mixy.
254
+ * Later runs (the manifest exists), so a downgrade to 1.3.0 and back loses
255
+ * nothing:
256
+ * - a bot 1.3.0 made in agents/ after the split is copied over as above;
257
+ * - a migrated bot whose agents/ original was edited after its copy (newer
258
+ * files, other content) while its bots/ copy was left as copied is
259
+ * copied again, keeping its bots/ route; when both sides changed the
260
+ * bots/ copy wins and the edit stays in agents/ (logged);
261
+ * - a migrated bot whose bots/<id> is gone (deleted for good) never comes
262
+ * back from its agents/ original;
263
+ * - Mixy, once made or used in agents/lead, fills what bots/mixy lacks.
264
+ * Every copy is staged and swapped in by rename; a failure before the
265
+ * manifest is published puts back what this run replaced, removes what it
266
+ * added, writes no manifest and leaves agents/ and every session untouched.
267
+ *
268
+ * Runs under the cross-process migration lock: a second process waits up to
269
+ * `lockWaitMs`, then reads the profile as it is (`{ failed, busy }`).
270
+ * `afterPublish` is an interleaving point for tests (a failure there is one
271
+ * after the commit point).
272
+ */
273
+ export function migrateBotsToOwnStore(options) {
274
+ const { dataDir, log, lockWaitMs = LOCK_WAIT_MS } = options;
275
+ try {
276
+ return withFileLockSync(join(dataDir, BOT_MIGRATION_LOCK), () => migrateLocked(options), {
277
+ timeoutMs: lockWaitMs,
278
+ });
279
+ } catch (error) {
280
+ // Only the lock fails here (migrateLocked reports its own failures).
281
+ const done = readBotMigration(dataDir);
282
+ log?.(`[bot-split] another start is migrating; this one reads the profile as it is: ${error?.message || error}`);
283
+ return { skipped: false, failed: true, busy: true, bots: done?.bots || [], mixy: done?.mixy === true };
284
+ }
285
+ }
286
+
287
+ function migrateLocked({ dataDir, shippedRoot, routeOf = () => null, leadRoute = null, backup, log, afterPublish }) {
288
+ const botsRoot = join(dataDir, BOTS_DIR);
289
+ const done = readManifestNow(botsRoot);
290
+ const runTag = `${process.pid}-${Date.now()}`;
291
+ // What this run changed, undone in reverse on a failure before the commit.
292
+ const changes = [];
293
+ let backedUp = false;
294
+ const beforeCopy = () => {
295
+ if (backedUp) return;
296
+ backedUp = true;
297
+ backup?.();
298
+ };
299
+ // Swaps a staged folder in for bots/<id> (atomic renames); the replaced
300
+ // folder is set aside as `.old` until the commit.
301
+ const swapIn = (id, staging, { replace }) => {
302
+ const target = join(botsRoot, id);
303
+ let old = '';
304
+ if (replace && existsSync(target)) {
305
+ old = join(botsRoot, `.${id}.${runTag}.old`);
306
+ renameSync(target, old);
307
+ }
308
+ try {
309
+ renameSync(staging, target);
310
+ } catch (error) {
311
+ removeBestEffort(staging);
312
+ // Should this fail too, the next start's sweep puts the `.old` back.
313
+ if (old) renameSync(old, target);
314
+ throw error;
315
+ }
316
+ changes.push({ target, old });
317
+ };
318
+ const stage = (id, source) => {
319
+ const staging = join(botsRoot, `.${id}.${runTag}.staging`);
320
+ rmSync(staging, { recursive: true, force: true });
321
+ if (source) cpSync(source, staging, { recursive: true });
322
+ else mkdirSync(staging, { recursive: true });
323
+ return staging;
324
+ };
325
+ let next;
326
+ try {
327
+ mkdirSync(botsRoot, { recursive: true });
328
+ sweepLeftovers(botsRoot, new Set(done?.retired || []));
329
+ // Committed set-asides the sweep could not remove stay named for a later start.
330
+ const retired = (done?.retired || []).filter((name) => existsSync(join(botsRoot, name)));
331
+ const copies = { ...(done?.copies || {}) };
332
+ const bots = new Set(done?.bots || []);
333
+ const record = (id, sourceDir) => {
334
+ copies[id] = { source: folderHash(sourceDir), copy: folderHash(join(botsRoot, id)), at: Date.now() };
335
+ };
336
+ // 1. Bots in agents/ not yet in the store.
337
+ for (const id of classifyBotIds({ dataDir, shippedRoot })) {
338
+ if (bots.has(id)) continue;
339
+ const source = join(dataDir, 'agents', id);
340
+ if (!existsSync(join(botsRoot, id))) {
341
+ beforeCopy();
342
+ const route = completeRoute(routeOf(id));
343
+ const staging = stage(id, source);
344
+ const manifest = readJson(join(staging, 'agent.json')) || {};
345
+ writeJson(join(staging, 'agent.json'), { ...manifest, ...(route ? { route } : {}) });
346
+ swapIn(id, staging, { replace: false });
347
+ }
348
+ bots.add(id);
349
+ record(id, source);
350
+ }
351
+ // 2. Migrated bots edited in agents/ after their copy (a 1.3.0 round trip).
352
+ for (const id of done ? [...bots] : []) {
353
+ const source = join(dataDir, 'agents', id);
354
+ const target = join(botsRoot, id);
355
+ const entry = copies[id];
356
+ if (!entry || !existsSync(target) || !existsSync(source)) continue;
357
+ const sourceChanged = folderHash(source) !== entry.source && newestChange(source) > entry.at;
358
+ if (!sourceChanged) continue;
359
+ if (folderHash(target) !== entry.copy) {
360
+ log?.(`[bot-split] ${id}: edited in both agents/ and bots/; the bots/ copy is kept`);
361
+ continue;
362
+ }
363
+ beforeCopy();
364
+ const route = readJson(join(target, 'agent.json'))?.route;
365
+ const staging = stage(id, source);
366
+ const manifest = readJson(join(staging, 'agent.json')) || {};
367
+ writeJson(join(staging, 'agent.json'), { ...manifest, ...(completeRoute(route) ? { route } : {}) });
368
+ swapIn(id, staging, { replace: true });
369
+ record(id, source);
370
+ }
371
+ // 3. Mixy: made or used in agents/lead; bots/mixy keeps what it has.
372
+ let mixy = done?.mixy === true;
373
+ const leadDir = join(dataDir, 'agents', 'lead');
374
+ const mixyDir = join(botsRoot, MIXY_BOT_ID);
375
+ const mixyEntry = copies[MIXY_BOT_ID];
376
+ const leadEdited =
377
+ mixyEntry &&
378
+ existsSync(mixyDir) &&
379
+ folderHash(leadDir) !== mixyEntry.source &&
380
+ newestChange(leadDir) > mixyEntry.at;
381
+ if (mixyToMigrate(dataDir) && (!mixyEntry || leadEdited)) {
382
+ const current = existsSync(mixyDir) ? readJson(join(mixyDir, 'agent.json')) || {} : {};
383
+ const untouched = mixyEntry && folderHash(mixyDir) === mixyEntry.copy;
384
+ if (mixyEntry && !untouched) {
385
+ log?.('[bot-split] Mixy: edited in both agents/lead and bots/mixy; bots/mixy is kept');
386
+ } else {
387
+ beforeCopy();
388
+ // A first copy fills what bots/mixy lacks (the host may have written
389
+ // its flag or route there); a re-sync takes agents/lead's identity.
390
+ const staging = stage(MIXY_BOT_ID, existsSync(mixyDir) && !mixyEntry ? mixyDir : null);
391
+ for (const name of folderFiles(leadDir).map((file) => relative(leadDir, file))) {
392
+ if (name === 'agent.json' || (existsSync(join(staging, name)) && !mixyEntry)) continue;
393
+ mkdirSync(dirname(join(staging, name)), { recursive: true });
394
+ cpSync(join(leadDir, name), join(staging, name));
395
+ }
396
+ const lead = readJson(join(leadDir, 'agent.json')) || {};
397
+ const identity = Object.fromEntries(
398
+ MIXY_IDENTITY_KEYS.filter((key) => key in lead).map((key) => [key, lead[key]])
399
+ );
400
+ const route =
401
+ completeRoute(current.route) || threadRoute(dataDir, MIXY_LEGACY_HOME_SESSION_ID) || completeRoute(leadRoute);
402
+ writeJson(join(staging, 'agent.json'), {
403
+ ...identity,
404
+ ...(mixyEntry ? {} : current),
405
+ ...(typeof current.created === 'boolean' ? { created: current.created } : {}),
406
+ ...(route ? { route } : {}),
407
+ homeSessionId: MIXY_LEGACY_HOME_SESSION_ID,
408
+ });
409
+ swapIn(MIXY_BOT_ID, staging, { replace: true });
410
+ record(MIXY_BOT_ID, leadDir);
411
+ mixy = true;
412
+ }
413
+ }
414
+ retired.push(...changes.filter(({ old }) => old).map(({ old }) => basename(old)));
415
+ next = {
416
+ version: BOT_MIGRATION_VERSION,
417
+ completedAt: done?.completedAt || new Date().toISOString(),
418
+ bots: [...bots].sort(),
419
+ mixy,
420
+ mixyHomeSessionId: mixy ? MIXY_LEGACY_HOME_SESSION_ID : null,
421
+ copies,
422
+ ...(retired.length ? { retired: retired.sort() } : {}),
423
+ };
424
+ // The commit point: once this rename lands, nothing of this run is undone.
425
+ if (!done || changes.length || JSON.stringify(next) !== JSON.stringify(done)) {
426
+ writeJsonAtomicSync(join(botsRoot, BOT_MIGRATION_MANIFEST), next, { mode: 0o644 });
427
+ }
428
+ } catch (error) {
429
+ // Undo this run: what it added goes, what it replaced comes back. A step
430
+ // that fails leaves an unretired `.old` the next start's sweep puts back.
431
+ for (const { target, old } of changes.reverse()) {
432
+ try {
433
+ const aside = join(botsRoot, `.${basename(target)}.${runTag}.staging`);
434
+ renameSync(target, aside);
435
+ if (old) renameSync(old, target);
436
+ removeBestEffort(aside);
437
+ } catch {
438
+ // the leftover sweep of a later start finishes it
439
+ }
440
+ }
441
+ for (const name of existsSync(botsRoot) ? readdirSync(botsRoot) : []) {
442
+ if (name.endsWith(`.${runTag}.staging`)) removeBestEffort(join(botsRoot, name));
443
+ }
444
+ log?.(`[bot-split] migration failed; the profile is unchanged: ${error?.message || error}`);
445
+ return { skipped: false, failed: true, bots: done?.bots || [], mixy: done?.mixy === true };
446
+ }
447
+ // Committed. Removing the set-aside copies is best-effort: the manifest
448
+ // names them as retired, so a later start removes what is left.
449
+ try {
450
+ afterPublish?.();
451
+ for (const { old } of changes) if (old) rmSync(old, { recursive: true, force: true });
452
+ } catch (error) {
453
+ log?.(`[bot-split] committed; leftover cleanup is retried next start: ${error?.message || error}`);
454
+ }
455
+ if (done && !changes.length) return { skipped: true, bots: next.bots, mixy: next.mixy };
456
+ return { skipped: false, bots: next.bots, mixy: next.mixy, changed: changes.map(({ target }) => basename(target)) };
457
+ }
@@ -43,6 +43,25 @@ function applyField(current, path, change) {
43
43
  return next;
44
44
  }
45
45
 
46
+ const isSet = (value) => value !== undefined && value !== null && value !== '';
47
+
48
+ function valueAt(source, path) {
49
+ let value = source;
50
+ for (const key of path) {
51
+ if (!isPlainObject(value) || !Object.hasOwn(value, key)) return undefined;
52
+ value = value[key];
53
+ }
54
+ return value;
55
+ }
56
+
57
+ /** A change with `unlessSet: [path…]` is skipped when the stored value at that
58
+ * path is already set. It is judged against `current`, the state before the
59
+ * whole patch, so the changes of one patch that share a guard stand or fall
60
+ * together whatever their order. */
46
61
  export function applyConfigPatch(current, changes) {
47
- return changes.reduce((next, change) => applyField(next, change.path, change), current);
62
+ return changes.reduce(
63
+ (next, change) =>
64
+ change.unlessSet && isSet(valueAt(current, change.unlessSet)) ? next : applyField(next, change.path, change),
65
+ current
66
+ );
48
67
  }
@@ -19,8 +19,7 @@ export type ConnectorBackend =
19
19
  placeholder?: string;
20
20
  /** Tools that ask before they run unless the agent set them. */
21
21
  askTools?: readonly string[];
22
- }
23
- | { kind: 'builtin'; feature: string };
22
+ };
24
23
 
25
24
  export interface ConnectorCatalogEntry {
26
25
  id: string;
@@ -4,9 +4,8 @@
4
4
  // explicitly:
5
5
  // oauth — a remote MCP server that signs in with MCP OAuth;
6
6
  // apiKey — an MCP server that takes one key (an HTTP header or a stdio
7
- // environment variable);
8
- // builtin — a Mixdog built-in feature (GitHub is the GitHub CLI of the
9
- // Git & GitHub built-in, never a GitHub MCP);
7
+ // environment variable).
8
+ // GitHub is not a connector: it is the signed-in GitHub CLI (github tool).
10
9
  // A connected catalog service is an ordinary user MCP server whose config
11
10
  // carries `connector: <id>`. An existing custom server is never adopted into
12
11
  // the catalog row.
@@ -50,7 +49,6 @@ export const CONNECTOR_CATALOG = Object.freeze(
50
49
  { id: 'airtable', name: 'Airtable', backend: oauth('https://mcp.airtable.com/mcp') },
51
50
  { id: 'confluence', name: 'Confluence', backend: oauth('https://mcp.atlassian.com/v1/mcp/authv2') },
52
51
  { id: 'dropbox', name: 'Dropbox', backend: oauth('https://mcp.dropbox.com/mcp') },
53
- { id: 'github', name: 'GitHub', backend: Object.freeze({ kind: 'builtin', feature: 'git' }) },
54
52
  {
55
53
  id: 'gmail',
56
54
  name: 'Gmail',
@@ -6,6 +6,8 @@
6
6
  export const CONNECTOR_TRIGGER_MAX_CHARS = 100;
7
7
 
8
8
  export const CONNECTOR_TRIGGERS = Object.freeze({
9
+ // Not a catalog connector (Mixdog uses the signed-in GitHub CLI), but a
10
+ // user-added `github` MCP server still gets this line.
9
11
  github: 'GitHub repos, issues, pull requests, Actions and code search; not local Git history or diffs.',
10
12
  notion: 'Notion pages, databases and workspace search; create or update team docs and notes.',
11
13
  linear: 'Linear issues, projects, cycles and comments; create, triage or update tickets.',
@@ -237,7 +237,9 @@ function typedStreamFailureText(error, text) {
237
237
 
238
238
  export function presentErrorText(error, options = {}) {
239
239
  const presentation = describeError(error);
240
- if (['image-size', 'payload-size', 'request'].includes(presentation.kind)) return presentation.summary;
240
+ if (['image-size', 'payload-size', 'request', 'model-unavailable', 'tool-schema'].includes(presentation.kind)) {
241
+ return presentation.summary;
242
+ }
241
243
  const kindLine = hasTypedEvidence(error) ? KIND_LINES[presentation.providerKind] : null;
242
244
  if (kindLine && presentation.kind !== 'unknown') {
243
245
  return typeof kindLine === 'function' ? kindLine(presentation) : kindLine;
@@ -138,6 +138,77 @@ function resetLabel(resetAt) {
138
138
  return reset.toDateString() === new Date().toDateString() ? time : `${reset.getMonth() + 1}/${reset.getDate()} ${time}`;
139
139
  }
140
140
 
141
+ // A refused request that names its model as unknown, retired or not offered
142
+ // to this account. Read only for request-refusal kinds, so a typed overload,
143
+ // quota or auth failure never reads as a missing model.
144
+ const MODEL_REFUSAL_KINDS = new Set(['invalid_request', 'permission', 'unknown']);
145
+ const MODEL_UNAVAILABLE_CODES = new Set(['model_not_found', 'model_not_supported']);
146
+ // The model must be the subject of the refusal, at the start of a line or
147
+ // sentence ("The model `x` does not exist", "models/x is not found", Ollama's
148
+ // `model "x" not found`), or the refusal names it ("Unknown model", "… is not
149
+ // a valid model ID", Anthropic's bare "model: x"). A model merely mentioned
150
+ // beside some other missing thing ("For model x, tool lookup: route not
151
+ // found") does not count.
152
+ const MODEL_REFUSAL_PREDICATE =
153
+ "(?:(?:is|was|has been)\\s+)?(?:not found|does not exist|doesn'?t exist|not (?:supported|available)|(?:currently\\s+)?unavailable|no longer (?:available|supported)|deprecated|retired|removed)\\b";
154
+ const SENTENCE_START = '(?:^|[.:;!]\\s+)';
155
+ const MODEL_UNAVAILABLE_RE = new RegExp(
156
+ [
157
+ // "The model `x` does not exist", "models/x is not found", Vertex
158
+ // "Publisher Model `…` was not found", Ollama `model "x" not found`.
159
+ `${SENTENCE_START}(?:(?:the|this|that|requested|selected|publisher)\\s+)*models?(?:\\/\\S+|\\s+[\`'"]?[\\w./:@-]+[\`'"]?)?\\s+${MODEL_REFUSAL_PREDICATE}`,
160
+ // OpenAI "The 'gpt-x' model is not supported when using Codex …".
161
+ `${SENTENCE_START}(?:the\\s+)?[\`'"][^\`'"\\n]+[\`'"]\\s+model\\s+${MODEL_REFUSAL_PREDICATE}`,
162
+ `${SENTENCE_START}(?:unknown|invalid|unsupported) model\\b`,
163
+ // OpenRouter: no provider serves this model id.
164
+ `${SENTENCE_START}no endpoints found for [\\w.-]+\\/[\\w.:-]+`,
165
+ '\\bis not a valid model\\b',
166
+ '^model: \\S+$',
167
+ ].join('|'),
168
+ 'im'
169
+ );
170
+
171
+ /**
172
+ * True when `error` (or its text) says the requested model is unknown,
173
+ * retired or not offered to this account: a typed model code, or provider
174
+ * wording with the model as the subject of the refusal.
175
+ */
176
+ export function namesUnavailableModel(error) {
177
+ if (!error) return false;
178
+ if (typeof error === 'string') return MODEL_UNAVAILABLE_RE.test(error);
179
+ const codes = [error.code, error.providerErrorCode, error.error?.code, error.providerError?.code];
180
+ if (codes.some((code) => MODEL_UNAVAILABLE_CODES.has(String(code || '').toLowerCase()))) return true;
181
+ return [error.message, error.error?.message, error.providerError?.message, error.detail]
182
+ .filter((text) => typeof text === 'string' && text)
183
+ .some((text) => MODEL_UNAVAILABLE_RE.test(text));
184
+ }
185
+ const MODEL_UNAVAILABLE_COPY = [
186
+ 'model-unavailable',
187
+ "This model isn't available for this account.",
188
+ 'Choose another model, or check the model and endpoint in Providers.',
189
+ ];
190
+ const NOT_FOUND_SUMMARY = "The provider couldn't find the requested model or endpoint.";
191
+ // A tool definition the provider's schema validator refused (an MCP or custom
192
+ // tool schema), named by the field path or wording each API uses.
193
+ const TOOL_SCHEMA_RE =
194
+ /invalid schema for (?:function|tool)\b|\btools\.\d+\.(?:custom\.)?input_schema\b|\bfunction_declarations\[\d+\]\.parameters\b|\btools\[\d+\]\.function\.parameters\b|\binvalid (?:tool|function) (?:schema|parameters|definition)\b/i;
195
+ const TOOL_SCHEMA_COPY = [
196
+ 'tool-schema',
197
+ 'The provider rejected a tool definition.',
198
+ 'Disable the tool or MCP server named in the details, then try again.',
199
+ ];
200
+
201
+ // The first line of a provider message worth showing as-is; '' for a stack
202
+ // frame, a markup page or a raw JSON body.
203
+ function firstReadableLine(clean) {
204
+ const lines = clean
205
+ .split('\n')
206
+ .map((line) => line.trim())
207
+ .filter(Boolean);
208
+ const first = lines.find((line) => !/^at\s|^\{|^<!doctype|^<html/i.test(line)) || '';
209
+ return first && !/^[{[]|^<|(?:API|HTTP)\s+[45]\d\d.*[<{]/i.test(first) ? first : '';
210
+ }
211
+
141
212
  // Only legacy items (no persisted descriptor) and untyped strings reach this.
142
213
  const LEGACY_TEXT_KINDS = [
143
214
  [/The provider usage limit was reached|quota.*(?:hit|exceed)|usage limit/i, 'quota_exhausted'],
@@ -241,6 +312,25 @@ export function describeError(value) {
241
312
  kind = 'image-size';
242
313
  summary = 'An attached image exceeds the provider limit.';
243
314
  recovery = 'Reduce the image dimensions or number of images, then try again.';
315
+ } else if (
316
+ clean === MODEL_UNAVAILABLE_COPY[1] ||
317
+ (MODEL_REFUSAL_KINDS.has(descriptor.kind) &&
318
+ (MODEL_UNAVAILABLE_CODES.has(code) || MODEL_UNAVAILABLE_RE.test(clean)))
319
+ ) {
320
+ [kind, summary, recovery] = MODEL_UNAVAILABLE_COPY;
321
+ } else if (
322
+ clean === TOOL_SCHEMA_COPY[1] ||
323
+ (['invalid_request', 'unknown'].includes(descriptor.kind) && TOOL_SCHEMA_RE.test(clean))
324
+ ) {
325
+ [kind, summary, recovery] = TOOL_SCHEMA_COPY;
326
+ } else if (
327
+ clean === NOT_FOUND_SUMMARY ||
328
+ (MODEL_REFUSAL_KINDS.has(descriptor.kind) && (status === 404 || status === 410))
329
+ ) {
330
+ // The provider's own words name what is missing; the next step is the same.
331
+ kind = 'not-found';
332
+ summary = firstReadableLine(clean) || NOT_FOUND_SUMMARY;
333
+ recovery = MODEL_UNAVAILABLE_COPY[2];
244
334
  } else if (
245
335
  descriptor.kind === 'unknown' &&
246
336
  /payload too large|request (?:body )?too large|The request exceeds the size limit/i.test(clean)
@@ -272,12 +362,7 @@ export function describeError(value) {
272
362
  summary = 'The provider rejected this request.';
273
363
  recovery = 'Review the request details before trying again.';
274
364
  } else {
275
- const lines = clean
276
- .split('\n')
277
- .map((line) => line.trim())
278
- .filter(Boolean);
279
- const first = lines.find((line) => !/^at\s|^\{|^<!doctype|^<html/i.test(line)) || '';
280
- summary = first && !/^[{[]|^<|(?:API|HTTP)\s+[45]\d\d.*[<{]/i.test(first) ? first : 'Something went wrong.';
365
+ summary = firstReadableLine(clean) || 'Something went wrong.';
281
366
  }
282
367
  summary = summary.replace(/\s+/g, ' ').trim() || 'Something went wrong.';
283
368
  if (summary.length > SUMMARY_LIMIT) summary = `${summary.slice(0, SUMMARY_LIMIT - 1).trimEnd()}…`;
@@ -0,0 +1,20 @@
1
+ export interface HandoffFile {
2
+ name: string;
3
+ path: string;
4
+ kind: 'image' | 'file' | 'text';
5
+ mimeType: string;
6
+ size: number;
7
+ /** Attachment-store reference when the file was stored. */
8
+ id?: string;
9
+ }
10
+ export function normalizeHandoffFiles(files: unknown): HandoffFile[];
11
+ export function artifactLines(files: unknown, omitted?: number, options?: { ids?: boolean }): string[];
12
+ export interface HandoffRecord {
13
+ id: string;
14
+ attachmentRef: string;
15
+ kind: 'image' | 'file' | 'text';
16
+ name: string;
17
+ mimeType: string;
18
+ size: number;
19
+ }
20
+ export function artifactRecords(files: unknown): HandoffRecord[];