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,61 @@
1
+ // The files a bot's or agent's turn handed back (session-runtime/
2
+ // handoff-attachments.mjs collectTurnArtifacts) as the rows that carry them
3
+ // show them: one model-facing line per file — the attachment id that forwards
4
+ // it, or its path when it is too large or of a type no prompt carries — and
5
+ // the message attachment records of the stored ones. Only those records
6
+ // authorize forwarding by id; the lines are information for the model.
7
+ import { escapeHarnessTags } from './shell-output-summary.mjs';
8
+ import { normalizeMessageAttachments } from './transcript-metadata.mjs';
9
+
10
+ const sizeLabel = (size) => {
11
+ const bytes = Number(size) || 0;
12
+ if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
13
+ return bytes >= 1024 ? `${Math.round(bytes / 1024)} KB` : `${bytes} B`;
14
+ };
15
+
16
+ const oneLine = (value) => escapeHarnessTags(String(value ?? '').replace(/[\r\n]+/g, ' '));
17
+
18
+ /** `files` ({ name, mimeType, size, path, id? }) normalized: bounded strings,
19
+ * ids only of the attachment-store shape. */
20
+ export function normalizeHandoffFiles(files) {
21
+ return (Array.isArray(files) ? files : [])
22
+ .filter((file) => file && typeof file === 'object' && typeof file.path === 'string' && file.path)
23
+ .slice(0, 8)
24
+ .map((file) => ({
25
+ name: String(file.name || 'file').slice(0, 200),
26
+ path: file.path.slice(0, 1024),
27
+ kind: ['image', 'file', 'text'].includes(file.kind) ? file.kind : 'file',
28
+ mimeType: String(file.mimeType || 'application/octet-stream').slice(0, 100),
29
+ size: Number(file.size) >= 0 ? Number(file.size) : 0,
30
+ ...(/^[a-f0-9]{64}$/.test(String(file.id ?? '')) ? { id: file.id } : {}),
31
+ }));
32
+ }
33
+
34
+ /** The model-facing list of `files`, empty when there are none. `ids`: the
35
+ * row also records the stored files as its attachments (a task report), so
36
+ * their ids can be forwarded; a plain-text handoff (an agent completion)
37
+ * lists paths only — an id named in text is never trusted. */
38
+ export function artifactLines(files, omitted = 0, { ids = true } = {}) {
39
+ const list = normalizeHandoffFiles(files);
40
+ if (!list.length) return [];
41
+ const attachable = (file) => (ids ? ` [attachment:${file.id}]` : ' (attachable: forward it by path)');
42
+ return [
43
+ `Files from this turn (forward one with attachments: [${ids ? 'id or path' : 'path'}]; link a path to show it):`,
44
+ ...list.map(
45
+ (file) =>
46
+ `- ${oneLine(file.name)} (${oneLine(file.mimeType)}, ${sizeLabel(file.size)}) — ${oneLine(file.path)}${
47
+ file.id ? attachable(file) : ' (too large or unsupported to attach; link the path)'
48
+ }`
49
+ ),
50
+ ...(omitted > 0 ? [`- …and ${omitted} more file(s)`] : []),
51
+ ];
52
+ }
53
+
54
+ /** The message attachment records of `files` (the stored ones). */
55
+ export function artifactRecords(files) {
56
+ return normalizeMessageAttachments(
57
+ normalizeHandoffFiles(files)
58
+ .filter((file) => file.id)
59
+ .map((file) => ({ ...file, attachmentRef: file.id }))
60
+ );
61
+ }
@@ -28,7 +28,12 @@ export const ANTIGRAVITY_MODELS = Object.freeze([
28
28
  low: 'gemini-3.1-pro-low',
29
29
  high: 'gemini-3.1-pro-high',
30
30
  }),
31
- fallbackModel('gemini-4-argon', 'Gemini 4 Argon', 'gemini-pro', 1048576, 'gemini-4-argon', ['low', 'medium', 'high']),
31
+ // The gateway serves Argon only through per-tier ids; bare `gemini-4-argon` is a 404.
32
+ fallbackModel('gemini-4-argon', 'Gemini 4 Argon', 'gemini-pro', 1048576, {
33
+ low: 'gemini-4-argon-low',
34
+ medium: 'gemini-4-argon-medium',
35
+ high: 'gemini-4-argon-high',
36
+ }),
32
37
  fallbackModel('claude-opus-4-6-thinking', 'Claude Opus 4.6 (Thinking)', null, 250000, 'claude-opus-4-6-thinking', [
33
38
  'low',
34
39
  'medium',
@@ -63,6 +63,12 @@ export async function accountProviderSend(provider, instance, send, model, opts
63
63
  if (!ledger) return;
64
64
  recorded.add(owner);
65
65
  const usage = result.usage || UNMEASURED_USAGE;
66
+ const durationMs = timed ? Date.now() - startedAt : 0;
67
+ // The one measured request time: the loop's speed figures read it, so
68
+ // the composer and the ledger show the same span.
69
+ if (timed && result && typeof result === 'object') {
70
+ Object.defineProperty(result, 'usageDurationMs', { value: durationMs, configurable: true, writable: true });
71
+ }
66
72
  const row = makeUsageRecord({
67
73
  id: result.responseId ? undefined : id,
68
74
  ts: Date.now(),
@@ -88,7 +94,7 @@ export async function accountProviderSend(provider, instance, send, model, opts
88
94
  // The account this send is bound to, so quota history can tell one
89
95
  // connected subscription's records from another's.
90
96
  account: ACCOUNT_PROVIDERS.includes(provider) ? currentProviderAccountId(provider) : '',
91
- durationMs: timed ? Date.now() - startedAt : 0,
97
+ durationMs,
92
98
  });
93
99
  // Committed by the ledger worker (batched with concurrent sends); the
94
100
  // send still settles only once its row is durable, as before, but the
@@ -1,10 +1,84 @@
1
- import { providerErrorKind } from '../agent/orchestrator/providers/provider-error-kind.mjs';
1
+ import {
2
+ boundedCauseChain,
3
+ providerErrorKind,
4
+ typedCodeKind,
5
+ } from '../agent/orchestrator/providers/provider-error-kind.mjs';
2
6
  import { readStreamOutcome } from '../agent/orchestrator/providers/lib/stream-outcome.mjs';
7
+ import { namesUnavailableModel } from './error-presentation.mjs';
3
8
 
4
- const ELIGIBLE = new Set(['rate_limited', 'quota_exhausted', 'overloaded', 'transient']);
9
+ // When a route's model fails, does the next model in its fallback chain get
10
+ // the request? Only for a refusal scoped to THIS provider, account or model —
11
+ // another provider may serve the identical request:
12
+ // quota_exhausted / entitlement / billing — the plan or account is spent;
13
+ // rate_limited / overloaded / service_tier_unavailable — this provider is
14
+ // saturated once its own retries are spent;
15
+ // auth / permission — the provider's own sign-in refresh already failed, or
16
+ // the account, region or tier is refused;
17
+ // transient — only when the server answered (a typed status or code): its
18
+ // retry ladder is spent. A bare network failure would fail on every
19
+ // provider, so it stays with the transport recovery;
20
+ // invalid_request on 404/410, a typed model code, or provider wording with
21
+ // the model as the refused subject (namesUnavailableModel) — the model is
22
+ // unknown, retired or not offered to this account.
23
+ // A request that fails the same way on any model never hops: request too
24
+ // large, a refused tool schema or other invalid request, a policy refusal,
25
+ // a cancel, a stall and any untyped failure. A context overflow hops only to
26
+ // a model with a larger window (`contextGrows`); compaction runs first.
27
+ // Whatever the kind, nothing may have been produced: no visible or buffered
28
+ // output, no tool call started or dispatched.
29
+ const PROVIDER_SCOPED_KINDS = new Set([
30
+ 'quota_exhausted',
31
+ 'entitlement',
32
+ 'billing',
33
+ 'rate_limited',
34
+ 'overloaded',
35
+ 'service_tier_unavailable',
36
+ 'auth',
37
+ 'permission',
38
+ ]);
39
+ const MODEL_UNAVAILABLE_CODES = new Set(['model_not_found', 'model_not_supported']);
5
40
 
6
- export function canFallbackRoute(error, { signal, outcome = readStreamOutcome(error), committed = false } = {}) {
7
- if (signal?.aborted || committed || outcome.userAbort || outcome.stallObserved) return false;
41
+ /** 'hop', 'context' (a context overflow: hop only to a larger window) or 'stay'. */
42
+ // A recovery layer may wrap the provider failure (a context overflow it could
43
+ // not compact away): what the original request exposed lives on the cause.
44
+ // Every node of the chain counts, so a wrapper never launders exposure.
45
+ function chainOutcome(error) {
46
+ return readStreamOutcome(...(error && typeof error === 'object' ? boundedCauseChain(error) : [error]));
47
+ }
48
+
49
+ export function routeFallbackVerdict(error, { signal, outcome = chainOutcome(error), committed = false } = {}) {
50
+ if (signal?.aborted || committed || outcome.userAbort || outcome.stallObserved) return 'stay';
51
+ if (!outcome.replaySafe || outcome.observedOutput || outcome.toolCallsStarted || outcome.pendingToolInput) {
52
+ return 'stay';
53
+ }
54
+ const { kind, status, code } = providerErrorKind(error);
55
+ if (PROVIDER_SCOPED_KINDS.has(kind)) return 'hop';
56
+ if (kind === 'transient') return status >= 408 || typedCodeKind(error) === 'transient' ? 'hop' : 'stay';
57
+ if (kind === 'invalid_request') {
58
+ return status === 404 || status === 410 || MODEL_UNAVAILABLE_CODES.has(code) || namesUnavailableModel(error)
59
+ ? 'hop'
60
+ : 'stay';
61
+ }
62
+ return kind === 'context_overflow' ? 'context' : 'stay';
63
+ }
64
+
65
+ export function canFallbackRoute(error, { contextGrows = false, ...options } = {}) {
66
+ const verdict = routeFallbackVerdict(error, options);
67
+ return verdict === 'hop' || (verdict === 'context' && contextGrows === true);
68
+ }
69
+
70
+ const CAPACITY_KINDS = new Set(['rate_limited', 'quota_exhausted', 'overloaded', 'transient']);
71
+
72
+ /**
73
+ * The narrower rule a stateless tool route (native web search) keeps: only a
74
+ * capacity refusal the server stated (429 / 503, or an account-pool quota
75
+ * refusal) moves to the next provider; a denied or failed credential
76
+ * surfaces so the user fixes that route.
77
+ */
78
+ // It also keeps its stricter stream guards: any started or terminal stream
79
+ // stays on its route, since a tool call cannot prove nothing was generated.
80
+ export function canFallbackCapacityRoute(error, { signal, outcome = readStreamOutcome(error) } = {}) {
81
+ if (signal?.aborted || outcome.userAbort || outcome.stallObserved) return false;
8
82
  if (
9
83
  !outcome.replaySafe ||
10
84
  outcome.observedOutput ||
@@ -15,8 +89,6 @@ export function canFallbackRoute(error, { signal, outcome = readStreamOutcome(er
15
89
  )
16
90
  return false;
17
91
  const { kind, status } = providerErrorKind(error);
18
- if (!ELIGIBLE.has(kind)) return false;
19
- // A rejected HTTP request or a provider account-pool refusal proves no
20
- // generation started. A transport disconnect alone does not.
92
+ if (!CAPACITY_KINDS.has(kind)) return false;
21
93
  return status === 429 || status === 503 || (kind === 'quota_exhausted' && error?.quotaExhausted != null);
22
94
  }
@@ -5,23 +5,28 @@
5
5
  /** `text` without markdown syntax, whitespace folded to single spaces. */
6
6
  export function plainPreviewText(text) {
7
7
  // HTML comments never show in markdown (a greeting's suggestions ride one).
8
- return String(text ?? '')
9
- .replace(/<!--[\s\S]*?(?:-->|$)/g, ' ')
10
- .replace(/^[ \t]*(`{3,}|~{3,})[^\n]*$/gm, ' ')
11
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
12
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
13
- .replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
14
- .replace(/^[ \t]*>[ \t]?/gm, '')
15
- .replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/gm, '')
16
- .replace(/^[ \t]*(?:[-*_][ \t]*){3,}$/gm, ' ')
17
- .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '$2')
18
- .replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?!\w)/g, '$1$2')
19
- .replace(/(^|[^\w_])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
20
- .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1')
21
- .replace(/`+([^`\n]*)`+/g, '$1')
22
- .replace(/\s+/g, ' ')
23
- .trim()
24
- .slice(0, PREVIEW_CAP);
8
+ return (
9
+ String(text ?? '')
10
+ .replace(/<!--[\s\S]*?(?:-->|$)/g, ' ')
11
+ .replace(/^[ \t]*(`{3,}|~{3,})[^\n]*$/gm, ' ')
12
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
13
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
14
+ .replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
15
+ .replace(/^[ \t]*>[ \t]?/gm, '')
16
+ .replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/gm, '')
17
+ .replace(/^[ \t]*(?:[-*_][ \t]*){3,}$/gm, ' ')
18
+ .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '$2')
19
+ .replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?!\w)/g, '$1$2')
20
+ .replace(/(^|[^\w_])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
21
+ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1')
22
+ .replace(/`+([^`\n]*)`+/g, '$1')
23
+ .replace(/\s+/g, ' ')
24
+ // A summary stored before newlines were kept already folded its list: a
25
+ // dash or bullet right after a sentence end is a marker, not a hyphen.
26
+ .replace(/([.!?。…::])\s[-*+•]\s+(?=\S)/g, '$1 ')
27
+ .trim()
28
+ .slice(0, PREVIEW_CAP)
29
+ );
25
30
  }
26
31
 
27
32
  /** A safety cap only: the row clamps the shown text to two lines. */
@@ -0,0 +1,15 @@
1
+ export interface SensitiveScope {
2
+ root: string[];
3
+ /** Subfolders of the root that stay reachable. */
4
+ except: readonly string[];
5
+ /** Whole tree (a registered private root) or Mixdog's secret subset. */
6
+ whole: boolean;
7
+ }
8
+ export function sensitivePathSegments(path: string): string[];
9
+ export function matchesSensitivePath(
10
+ path: string,
11
+ within?: readonly SensitiveScope[],
12
+ options?: { envTemplates?: boolean }
13
+ ): boolean;
14
+ export function mixdogSensitiveScopes(extraData?: readonly string[]): SensitiveScope[];
15
+ export function isSensitiveLocalPath(path: string, options?: { extraData?: readonly string[] }): boolean;
@@ -0,0 +1,256 @@
1
+ // The credential / secret file deny list, shared by every lane that hands a
2
+ // local file to someone else: the desktop's remote (paired-device) file lanes
3
+ // (apps/desktop/src/main/remote-sensitive-paths.ts adds its registered private
4
+ // roots and async canonicalization) and the runtime's attachment hand-offs
5
+ // between bots and agents (session-runtime/handoff-attachments.mjs).
6
+ //
7
+ // Matching is on SEGMENTS of a path, lower-cased on every platform, so a
8
+ // denied name cannot hide behind letter case, trailing dots/spaces or an NTFS
9
+ // stream suffix. Callers pass the canonical (realpath) form as well as the
10
+ // lexical one, so a link cannot reach a denied place under an innocent name.
11
+ import { realpathSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join, resolve } from 'node:path';
14
+
15
+ /** A directory with this name anywhere on the path holds credentials. `.git`
16
+ * is one of them: its config carries remotes with embedded tokens and
17
+ * credential helpers, and its objects hold every secret ever committed. */
18
+ const SECRET_DIRECTORIES = new Set([
19
+ '.ssh',
20
+ '.gnupg',
21
+ '.aws',
22
+ '.azure',
23
+ '.kube',
24
+ '.mozilla',
25
+ '.password-store',
26
+ '.git',
27
+ ]);
28
+
29
+ /** Consecutive segments anywhere on the path that name a credential store:
30
+ * CLI configs, browser profiles and OS credential stores. */
31
+ const SECRET_SEQUENCES = [
32
+ // Tool credentials in the user's home.
33
+ ['.docker', 'config.json'],
34
+ ['.config', 'gcloud'],
35
+ ['appdata', 'roaming', 'gcloud'],
36
+ ['.config', 'gh'],
37
+ ['appdata', 'roaming', 'github cli'],
38
+ ['.config', 'git', 'credentials'],
39
+ ['.cargo', 'credentials'],
40
+ ['.cargo', 'credentials.toml'],
41
+ ['.terraform.d', 'credentials.tfrc.json'],
42
+ ['.claude', '.credentials.json'],
43
+ ['.codex', 'auth.json'],
44
+ ['.gemini', 'oauth_creds.json'],
45
+ // Browser profiles: cookies, saved logins, session tokens.
46
+ ['google', 'chrome', 'user data'],
47
+ ['microsoft', 'edge', 'user data'],
48
+ ['bravesoftware', 'brave-browser'],
49
+ ['chromium', 'user data'],
50
+ ['vivaldi', 'user data'],
51
+ ['opera software'],
52
+ ['mozilla', 'firefox'],
53
+ ['application support', 'google', 'chrome'],
54
+ ['application support', 'microsoft edge'],
55
+ ['application support', 'firefox'],
56
+ ['application support', 'chromium'],
57
+ ['application support', 'bravesoftware'],
58
+ ['application support', 'vivaldi'],
59
+ ['library', 'safari'],
60
+ ['library', 'cookies'],
61
+ ['.config', 'google-chrome'],
62
+ ['.config', 'chromium'],
63
+ ['.config', 'microsoft-edge'],
64
+ ['.config', 'bravesoftware'],
65
+ ['.config', 'vivaldi'],
66
+ ['snap', 'firefox'],
67
+ ['snap', 'chromium'],
68
+ ['.var', 'app', 'org.mozilla.firefox'],
69
+ ['.var', 'app', 'com.google.chrome'],
70
+ // OS credential stores.
71
+ ['microsoft', 'credentials'],
72
+ ['microsoft', 'protect'],
73
+ ['microsoft', 'vault'],
74
+ ['windows', 'system32', 'config'],
75
+ ['library', 'keychains'],
76
+ ['.local', 'share', 'keyrings'],
77
+ ['.local', 'share', 'kwalletd'],
78
+ ['etc', 'shadow'],
79
+ ['etc', 'gshadow'],
80
+ ['etc', 'ssh'],
81
+ ['etc', 'ssl', 'private'],
82
+ ];
83
+
84
+ const KEY_EXTENSIONS = new Set([
85
+ '.pem',
86
+ '.key',
87
+ '.p12',
88
+ '.pfx',
89
+ '.p8',
90
+ '.ppk',
91
+ '.jks',
92
+ '.keystore',
93
+ '.kdbx',
94
+ '.keychain',
95
+ '.keychain-db',
96
+ ]);
97
+ const CREDENTIAL_FILES = new Set([
98
+ '.netrc',
99
+ '_netrc',
100
+ '.npmrc',
101
+ '.pypirc',
102
+ '.git-credentials',
103
+ '.pgpass',
104
+ '.vault-token',
105
+ '.htpasswd',
106
+ '.dockercfg',
107
+ '.envrc',
108
+ '.s3cfg',
109
+ 'kubeconfig',
110
+ ]);
111
+ /** Environment files: every `.env*` name (`.env`, `.env.local`, `.envrc`,
112
+ * `.environment`, and the `.env.example` templates too — a template is
113
+ * checked in often enough with real values that no name is exempt) and
114
+ * `<stage>.env`. */
115
+ const ENV_FILE = /^(?:\.env.*|[^.]{1,64}(?:\.[^.]{1,64}){0,3}\.env)$/;
116
+ /** The dotenv templates a person may still open on purpose from a paired
117
+ * device (the desktop's remote file lanes, `envTemplates: true`); automatic
118
+ * transfers between bots and agents never get this allowance. */
119
+ const ENV_TEMPLATES = new Set(['.env.example', '.env.sample', '.env.template', '.env.dist']);
120
+ /** Credential JSON a cloud or OAuth client writes or downloads:
121
+ * `credentials.json`, `*credentials*.json`, `service-account*.json`,
122
+ * `client_secret*.json`. */
123
+ const CREDENTIAL_JSON = /^(?:.*credential.*|service[-_ ]?account.*|client[-_]?secret.*)\.json$/;
124
+ /** OpenSSH private keys (`id_ed25519`, `id_rsa_work`, `id_ecdsa_sk`, any
125
+ * `id_<type>`): no extension, so the public `.pub` half and documents named
126
+ * after a key type (`id_rsa_notes.md`) are not keys. */
127
+ const SSH_PRIVATE_KEY = /^id_[a-z0-9]{2,16}(?:[_-][^._-]{1,32}){0,3}$/;
128
+ /** A copy an editor or tool leaves beside a file (`x.bak`, `x.old`, `x~`,
129
+ * `x.1`): it holds what the original holds. */
130
+ const BACKUP_SUFFIX = /(?:\.(?:bak|backup|old|orig|prev|save|sav|swp|swo|tmp|copy|\d{1,3})|~)$/;
131
+ const MAX_BACKUP_SUFFIXES = 3;
132
+
133
+ /** The name a backup copy was made of (`.npmrc.bak.1` → `.npmrc`). */
134
+ function backupOriginal(name) {
135
+ let original = name;
136
+ for (let strip = 0; strip < MAX_BACKUP_SUFFIXES; strip += 1) {
137
+ const next = original.replace(BACKUP_SUFFIX, '');
138
+ if (next === original || !next) break;
139
+ original = next;
140
+ }
141
+ return original;
142
+ }
143
+
144
+ /** A file whose own name says it holds a secret, wherever it lives: a
145
+ * credential file, a dotenv file, a key or keystore, or a backup of one. */
146
+ function secretFileName(name, { envTemplates = false } = {}) {
147
+ const original = backupOriginal(name);
148
+ if (CREDENTIAL_FILES.has(original) || CREDENTIAL_JSON.test(original)) return true;
149
+ if (ENV_FILE.test(original)) return !(envTemplates && ENV_TEMPLATES.has(original));
150
+ const dot = original.lastIndexOf('.');
151
+ if (dot >= 0 && KEY_EXTENSIONS.has(original.slice(dot))) return true;
152
+ return SSH_PRIVATE_KEY.test(original);
153
+ }
154
+
155
+ // Mixdog's own state. Its home and data folders also hold what tools write
156
+ // (tool results, shell output, media), which stay reachable; only the files
157
+ // and folders that carry provider tokens, API keys, pairing and bridge secrets
158
+ // are denied.
159
+ const MIXDOG_SECRET_DIRECTORIES = new Set([
160
+ 'secrets',
161
+ 'mcp-oauth',
162
+ 'provider-accounts',
163
+ 'telegram',
164
+ 'relay-data',
165
+ 'dev-profile',
166
+ ]);
167
+ const MIXDOG_SECRET_FILE =
168
+ /^\.?(mixdog-config|provider-accounts|relay-hook-device|auth\.json|[a-z0-9-]+-oauth(-credentials)?\.json|[a-z0-9-]+-bridge\.json)/;
169
+ const MIXDOG_SECRET_EXTENSIONS = new Set(['.dpapi', '.token']);
170
+
171
+ function mixdogSecret(rest) {
172
+ if (rest.some((segment) => MIXDOG_SECRET_DIRECTORIES.has(segment))) return true;
173
+ const name = rest[rest.length - 1] ?? '';
174
+ const dot = name.lastIndexOf('.');
175
+ return MIXDOG_SECRET_FILE.test(name) || MIXDOG_SECRET_EXTENSIONS.has(dot >= 0 ? name.slice(dot) : '');
176
+ }
177
+
178
+ /** The segments of a path as the rules compare them: no extended-path prefix,
179
+ * no NTFS stream suffix, no trailing dots or spaces, lower case. */
180
+ export function sensitivePathSegments(path) {
181
+ return String(path)
182
+ .replace(/^[\\/]{2}[?.][\\/]/, '')
183
+ .split(/[\\/]+/)
184
+ .map((segment) =>
185
+ segment
186
+ .split(':')[0]
187
+ .replace(/[. ]+$/, '')
188
+ .toLowerCase()
189
+ )
190
+ .filter(Boolean);
191
+ }
192
+
193
+ function hasRun(segments, run) {
194
+ for (let start = 0; start + run.length <= segments.length; start += 1) {
195
+ if (run.every((segment, offset) => segments[start + offset] === segment)) return true;
196
+ }
197
+ return false;
198
+ }
199
+
200
+ /**
201
+ * Whether `path` is denied under `within`: scopes `{ root: segments, except:
202
+ * subfolder names, whole }` — Mixdog's home and data folders (`whole: false`,
203
+ * only their secret subset is denied) and registered private roots (`whole:
204
+ * true`, everything but `except`). Pass both the lexical and canonical form.
205
+ * `envTemplates`: a user-initiated remote open may read the dotenv
206
+ * templates; nothing else gets that allowance.
207
+ */
208
+ export function matchesSensitivePath(path, within = [], { envTemplates = false } = {}) {
209
+ const segments = sensitivePathSegments(resolve(path));
210
+ const name = segments[segments.length - 1] ?? '';
211
+ if (segments.some((segment) => SECRET_DIRECTORIES.has(segment))) return true;
212
+ if (secretFileName(name, { envTemplates }) || SECRET_SEQUENCES.some((run) => hasRun(segments, run))) return true;
213
+ const containing = within.filter(
214
+ ({ root }) => root.length > 0 && root.every((segment, index) => segments[index] === segment)
215
+ );
216
+ // Mixdog's home and data folders follow Mixdog's own rule even inside a
217
+ // registered private root: a dev or isolated profile keeps them in the app
218
+ // profile, and the tool output and media there stay reachable.
219
+ const own = containing.filter(({ whole }) => !whole);
220
+ if (own.length) return own.some(({ root }) => mixdogSecret(segments.slice(root.length)));
221
+ return containing.some(({ root, except }) => {
222
+ const rest = segments.slice(root.length);
223
+ return rest.length > 0 && !except.includes(rest[0]);
224
+ });
225
+ }
226
+
227
+ function realOrSelf(path) {
228
+ try {
229
+ return realpathSync.native(path);
230
+ } catch {
231
+ const parent = dirname(path);
232
+ return parent === path ? path : join(realOrSelf(parent), path.slice(parent.length).replace(/^[\\/]+/, ''));
233
+ }
234
+ }
235
+
236
+ /** Mixdog's home and data folders (MIXDOG_HOME / MIXDOG_DATA_DIR, plus any
237
+ * extra data folder the caller names) as `matchesSensitivePath` scopes. */
238
+ export function mixdogSensitiveScopes(extraData = []) {
239
+ const home = process.env.MIXDOG_HOME || join(homedir(), '.mixdog');
240
+ const roots = [home, process.env.MIXDOG_DATA_DIR || join(home, 'data'), ...extraData].filter(Boolean);
241
+ const scopes = [];
242
+ for (const root of roots) {
243
+ for (const form of new Set([resolve(root), realOrSelf(resolve(root))])) {
244
+ scopes.push({ root: sensitivePathSegments(form), except: [], whole: false });
245
+ }
246
+ }
247
+ return scopes;
248
+ }
249
+
250
+ /** Whether `path` — its own name or what its links lead to — is a
251
+ * credential/secret file under the shared deny list and Mixdog's own scopes. */
252
+ export function isSensitiveLocalPath(path, { extraData = [] } = {}) {
253
+ const within = mixdogSensitiveScopes(extraData);
254
+ const absolute = resolve(path);
255
+ return matchesSensitivePath(absolute, within) || matchesSensitivePath(realOrSelf(absolute), within);
256
+ }
@@ -6,13 +6,15 @@
6
6
  // - cache hit = cacheRead / (input + cacheRead + cacheWrite) over every call
7
7
  // whose prompt was measured. A call that could not measure its prompt
8
8
  // (promptKnown === false) has unknown zeros, so it adds nothing.
9
- // - speed = output tokens over generation ms (first visible token → end of the
10
- // response) of the calls long enough to time. A reply too short says nothing
11
- // about the model's speed, so each call is held to the floors below BEFORE it
12
- // is summed.
9
+ // - speed = output tokens over request ms (provider request start → end of the
10
+ // response, the wait for the first token included: the same wall time as the
11
+ // usage ledger's `duration_ms`) of every call with positive output and
12
+ // positive duration: the same eligibility as the usage ledger and the usage
13
+ // dialog, so the readout and the dialog agree. Records saved before this
14
+ // definition carry `speedOutputTokens` / `speedMs` (first visible token →
15
+ // end); they are kept as they are and read only while no request-timed call
16
+ // has landed.
13
17
  // - total tokens = input + cacheRead + cacheWrite + output, from the same sums.
14
- export const MIN_SPEED_OUTPUT_TOKENS = 20;
15
- export const MIN_SPEED_MS = 500;
16
18
 
17
19
  function num(value) {
18
20
  const n = Number(value);
@@ -21,16 +23,16 @@ function num(value) {
21
23
 
22
24
  /** The zeroed totals record. */
23
25
  export function emptyUsageReadout() {
24
- return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, speedOutputTokens: 0, speedMs: 0 };
26
+ return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, requestOutputTokens: 0, requestMs: 0 };
25
27
  }
26
28
 
27
29
  /**
28
- * Fold one `onUsageDelta` event (and the call's generation ms, 0 when it was
30
+ * Fold one `onUsageDelta` event (and the call's request ms, 0 when it was
29
31
  * not timed) into the totals; returns a new record, never mutates `totals`.
30
32
  * Providers differ on whether input counts cache; the prompt total does not,
31
33
  * so fresh input is the prompt less its cache read/write.
32
34
  */
33
- export function foldUsageReadout(totals, delta = {}, generationMs = 0) {
35
+ export function foldUsageReadout(totals, delta = {}, requestMs = 0) {
34
36
  const base = totals || emptyUsageReadout();
35
37
  const next = { ...base };
36
38
  if (delta.promptKnown !== false) {
@@ -47,10 +49,10 @@ export function foldUsageReadout(totals, delta = {}, generationMs = 0) {
47
49
  // partial one would pass for the whole: it stays without until the session
48
50
  // starts over.
49
51
  if (Number.isFinite(base.output)) next.output = base.output + output;
50
- const ms = num(generationMs);
51
- if (ms >= MIN_SPEED_MS && output >= MIN_SPEED_OUTPUT_TOKENS) {
52
- next.speedOutputTokens = num(base.speedOutputTokens) + output;
53
- next.speedMs = num(base.speedMs) + ms;
52
+ const ms = num(requestMs);
53
+ if (ms > 0 && output > 0) {
54
+ next.requestOutputTokens = num(base.requestOutputTokens) + output;
55
+ next.requestMs = num(base.requestMs) + ms;
54
56
  }
55
57
  return next;
56
58
  }
@@ -452,7 +452,10 @@ function finishBackup(dataDir, backupDir, reason) {
452
452
  legacyEvidenceCache.delete(normalizeDataDir(dataDir));
453
453
  }
454
454
 
455
- export function backupUserData(dataDir, reason = 'snapshot') {
455
+ /** `dirs`: more data-dir folders this one snapshot also carries (a one-time
456
+ * migration's, e.g. agents/ and bots/); routine snapshots keep to
457
+ * USER_DATA_DIRS so the ring stays small. */
458
+ export function backupUserData(dataDir, reason = 'snapshot', { dirs = [] } = {}) {
456
459
  if (skipBackups()) return { dir: null, copied: [] };
457
460
  if (!dataDir || !existsSync(dataDir)) return { dir: null, copied: [] };
458
461
  const scopeDir = scopeDirFor(dataDir);
@@ -462,7 +465,7 @@ export function backupUserData(dataDir, reason = 'snapshot') {
462
465
  const src = join(dataDir, rel);
463
466
  if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
464
467
  }
465
- for (const rel of USER_DATA_DIRS) {
468
+ for (const rel of [...USER_DATA_DIRS, ...dirs]) {
466
469
  const src = join(dataDir, rel);
467
470
  if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
468
471
  }