aiden-runtime 4.8.0 → 4.9.0

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 (99) hide show
  1. package/README.md +88 -1
  2. package/dist/cli/v4/aidenCLI.js +35 -4
  3. package/dist/cli/v4/chatSession.js +43 -16
  4. package/dist/cli/v4/commands/daemon.js +47 -2
  5. package/dist/cli/v4/commands/daemonDoctor.js +212 -0
  6. package/dist/cli/v4/commands/daemonStatus.js +1 -1
  7. package/dist/cli/v4/commands/help.js +2 -0
  8. package/dist/cli/v4/commands/hooks.js +428 -0
  9. package/dist/cli/v4/commands/index.js +5 -1
  10. package/dist/cli/v4/commands/mcp.js +89 -1
  11. package/dist/cli/v4/commands/mcpClientInstall.js +359 -0
  12. package/dist/cli/v4/commands/memory.js +702 -0
  13. package/dist/cli/v4/commands/recovery.js +1 -1
  14. package/dist/cli/v4/commands/skin.js +7 -0
  15. package/dist/cli/v4/commands/theme.js +217 -0
  16. package/dist/cli/v4/commands/trigger.js +1 -1
  17. package/dist/cli/v4/commands/update.js +14 -2
  18. package/dist/cli/v4/design/tokens.js +52 -4
  19. package/dist/cli/v4/display.js +102 -46
  20. package/dist/cli/v4/pasteIntercept.js +214 -70
  21. package/dist/cli/v4/replyRenderer.js +145 -5
  22. package/dist/cli/v4/skinEngine.js +67 -0
  23. package/dist/core/v4/aidenAgent.js +45 -2
  24. package/dist/core/v4/daemon/api/runs.js +131 -0
  25. package/dist/core/v4/daemon/bootstrap.js +368 -13
  26. package/dist/core/v4/daemon/db/migrations.js +169 -0
  27. package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +128 -0
  28. package/dist/core/v4/daemon/incarnationStore.js +47 -0
  29. package/dist/core/v4/daemon/runs/attemptStore.js +67 -0
  30. package/dist/core/v4/daemon/runs/reclaim.js +88 -0
  31. package/dist/core/v4/daemon/runs/retryPolicy.js +73 -0
  32. package/dist/core/v4/daemon/runs/runWithRetry.js +80 -0
  33. package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +72 -0
  34. package/dist/core/v4/daemon/spans/spanHelpers.js +272 -0
  35. package/dist/core/v4/daemon/spans/spanStore.js +113 -0
  36. package/dist/core/v4/daemon/triggerBus.js +50 -19
  37. package/dist/core/v4/hooks/auditQuery.js +67 -0
  38. package/dist/core/v4/hooks/dispatcher.js +286 -0
  39. package/dist/core/v4/hooks/index.js +46 -0
  40. package/dist/core/v4/hooks/lifecycle.js +27 -0
  41. package/dist/core/v4/hooks/manifest.js +142 -0
  42. package/dist/core/v4/hooks/registry.js +149 -0
  43. package/dist/core/v4/hooks/runtime/subprocessRunner.js +188 -0
  44. package/dist/core/v4/hooks/toolHookGate.js +76 -0
  45. package/dist/core/v4/hooks/trust.js +14 -0
  46. package/dist/core/v4/identity/contextManager.js +83 -0
  47. package/dist/core/v4/identity/daemonId.js +85 -0
  48. package/dist/core/v4/identity/enforcement.js +103 -0
  49. package/dist/core/v4/identity/executionContext.js +153 -0
  50. package/dist/core/v4/identity/hookExecution.js +62 -0
  51. package/dist/core/v4/identity/httpContext.js +68 -0
  52. package/dist/core/v4/identity/ids.js +185 -0
  53. package/dist/core/v4/identity/index.js +60 -0
  54. package/dist/core/v4/identity/subprocessContext.js +98 -0
  55. package/dist/core/v4/identity/traceparent.js +114 -0
  56. package/dist/core/v4/logger/index.js +3 -1
  57. package/dist/core/v4/logger/logger.js +28 -1
  58. package/dist/core/v4/logger/redact.js +149 -0
  59. package/dist/core/v4/logger/sinks/fileSink.js +13 -0
  60. package/dist/core/v4/logger/sinks/stdSink.js +19 -1
  61. package/dist/core/v4/mcp/install/backup.js +78 -0
  62. package/dist/core/v4/mcp/install/clientPaths.js +90 -0
  63. package/dist/core/v4/mcp/install/clients.js +203 -0
  64. package/dist/core/v4/mcp/install/healthCheck.js +83 -0
  65. package/dist/core/v4/mcp/install/jsoncMerge.js +174 -0
  66. package/dist/core/v4/mcp/install/profiles.js +109 -0
  67. package/dist/core/v4/mcp/install/wslDetect.js +62 -0
  68. package/dist/core/v4/memory/namespaceRegistry.js +117 -0
  69. package/dist/core/v4/memory/projectRoot.js +76 -0
  70. package/dist/core/v4/memory/reviewer/index.js +162 -0
  71. package/dist/core/v4/memory/reviewer/pendingStore.js +136 -0
  72. package/dist/core/v4/memory/reviewer/prompt.js +105 -0
  73. package/dist/core/v4/memory/reviewer/skipRules.js +92 -0
  74. package/dist/core/v4/memoryManager.js +57 -10
  75. package/dist/core/v4/paths.js +2 -0
  76. package/dist/core/v4/promptBuilder.js +6 -0
  77. package/dist/core/v4/subagent/spawnSubAgent.js +20 -7
  78. package/dist/core/v4/theme/bundledThemes.js +106 -0
  79. package/dist/core/v4/theme/themeLoader.js +160 -0
  80. package/dist/core/v4/theme/themeRegistry.js +97 -0
  81. package/dist/core/v4/theme/themeWatcher.js +95 -0
  82. package/dist/core/v4/toolRegistry.js +71 -8
  83. package/dist/core/v4/update/executeInstall.js +10 -6
  84. package/dist/core/v4/update/installMethodDetect.js +7 -0
  85. package/dist/core/version.js +67 -2
  86. package/dist/moat/approvalEngine.js +4 -0
  87. package/dist/moat/memoryGuard.js +8 -1
  88. package/dist/providers/v4/anthropicAdapter.js +10 -4
  89. package/dist/tools/v4/backends/local.js +19 -2
  90. package/dist/tools/v4/sessions/recallSession.js +6 -1
  91. package/package.json +3 -3
  92. package/plugins/aiden-plugin-chatgpt-plus/index.js +10 -1
  93. package/themes/default.yaml +52 -0
  94. package/themes/dracula.yaml +32 -0
  95. package/themes/light.yaml +32 -0
  96. package/themes/monochrome.yaml +31 -0
  97. package/themes/tokyo-night.yaml +32 -0
  98. package/dist/core/pluginSystem.js +0 -121
  99. package/dist/tools/v4/ui/_uiSmokeTool.js +0 -60
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/memory/reviewer/skipRules.ts — v4.9.0 Slice 10.
10
+ *
11
+ * Hard skip rules applied to every reviewer-proposed candidate AFTER
12
+ * the LLM returns. Belt-and-braces — the reviewer prompt also tells
13
+ * the LLM not to propose these, but the parser drops violators
14
+ * defensively because LLMs occasionally disregard prompt constraints.
15
+ *
16
+ * Rules (each has a `class` label that appears in the drop log):
17
+ * - sensitive_class — PII / medical / political / religious / financial inference
18
+ * - negation — "not X", "doesn't Y", "no longer Z"
19
+ * - transient — "this session", "today", "just now", "recently"
20
+ * - duplicate — substring overlap with an existing live entry
21
+ * - char_cap — > 200 chars
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.MAX_CANDIDATE_CHARS = void 0;
25
+ exports.evaluateCandidate = evaluateCandidate;
26
+ const SENSITIVE_PATTERNS = [
27
+ // Medical / health (broad — includes common conditions, treatments, symptoms)
28
+ /\b(diagnos|prescrib|medication|illness|disorder|disease|therap|antidepress|mental[- ]health|disabilit|cancer|tumou?r|anxiety|depress|HIV|AIDS\b|chronic|symptom)/i,
29
+ // Political / religious
30
+ /\b(votes? for|political affiliat|religious|conservative|liberal|democrat|republican|catholic|muslim|jewish|hindu|atheist)/i,
31
+ // Financial inference
32
+ /\b(income|salary|net worth|debt|bankrupt|tax bracket|credit score)/i,
33
+ // Sexual orientation / family planning
34
+ /\b(sexual orientat|\bgay\b|lesbian|straight\b|transgender|pregnan|fertility|miscarriage)/i,
35
+ ];
36
+ const NEGATION_PROBES = [
37
+ // Explicit "X does/did/is not Y" — match the negative verb forms,
38
+ // not the prefix. The reviewer often phrases candidates as
39
+ // "User does not …" so we match `does not`, `doesn't`, etc.
40
+ /\b(?:does\s*not|did\s*not|do\s*not|is\s*not|are\s*not|was\s*not|were\s*not|will\s*not|cannot|can\s*not|could\s*not|should\s*not|would\s*not)\b/i,
41
+ // Contracted forms — match across straight quote, smart quote, or
42
+ // apostrophe-less typo ("dont").
43
+ /\b(?:don[''’]?t|doesn[''’]?t|didn[''’]?t|isn[''’]?t|aren[''’]?t|wasn[''’]?t|weren[''’]?t|won[''’]?t|can[''’]?t|couldn[''’]?t|shouldn[''’]?t|wouldn[''’]?t)\b/i,
44
+ // "no longer" / "never" / leading "not"
45
+ /\b(?:no longer|never)\b/i,
46
+ /^\s*not\s+\w/i,
47
+ ];
48
+ const TRANSIENT_MARKERS = [
49
+ /\b(this session|this conversation|just now|right now|currently|today|tomorrow|yesterday|recently|earlier|a moment ago|this turn|last turn)\b/i,
50
+ ];
51
+ exports.MAX_CANDIDATE_CHARS = 200;
52
+ /**
53
+ * Fuzzy duplicate detection: candidate is dropped if it's a substring
54
+ * of any live entry (or vice-versa), case-insensitively. Cheap heuristic
55
+ * — matches the Slice 9 `MemoryManager.add()` substring-dedup pattern.
56
+ */
57
+ function isDuplicate(candidate, liveEntries) {
58
+ const c = candidate.toLowerCase().trim();
59
+ if (c.length < 8)
60
+ return false; // too short to dedup against
61
+ for (const live of liveEntries) {
62
+ const l = live.toLowerCase().trim();
63
+ if (l.length === 0)
64
+ continue;
65
+ if (l.includes(c) || c.includes(l))
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ /** Evaluate a candidate against every rule. Returns the first hit. */
71
+ function evaluateCandidate(candidate, liveEntries) {
72
+ const trimmed = candidate.trim();
73
+ if (trimmed.length === 0)
74
+ return { drop: true, klass: 'char_cap' };
75
+ if (trimmed.length > exports.MAX_CANDIDATE_CHARS)
76
+ return { drop: true, klass: 'char_cap' };
77
+ for (const p of SENSITIVE_PATTERNS) {
78
+ if (p.test(trimmed))
79
+ return { drop: true, klass: 'sensitive_class' };
80
+ }
81
+ for (const p of NEGATION_PROBES) {
82
+ if (p.test(trimmed))
83
+ return { drop: true, klass: 'negation' };
84
+ }
85
+ for (const p of TRANSIENT_MARKERS) {
86
+ if (p.test(trimmed))
87
+ return { drop: true, klass: 'transient' };
88
+ }
89
+ if (isDuplicate(trimmed, liveEntries))
90
+ return { drop: true, klass: 'duplicate' };
91
+ return { drop: false };
92
+ }
@@ -50,6 +50,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
50
50
  exports.MemoryManager = exports.ENTRY_SEPARATOR = exports.USER_CHAR_LIMIT = exports.MEMORY_CHAR_LIMIT = void 0;
51
51
  const node_fs_1 = require("node:fs");
52
52
  const node_path_1 = __importDefault(require("node:path"));
53
+ // v4.9.0 Slice 11 — namespace registry generalizes 'memory'|'user' to N.
54
+ const namespaceRegistry_1 = require("./memory/namespaceRegistry");
53
55
  /**
54
56
  * Char budgets per arch doc Memory section. NOT tokens — char counts are
55
57
  * model-independent and easy to enforce client-side.
@@ -59,8 +61,11 @@ exports.USER_CHAR_LIMIT = 1375;
59
61
  /** Entry separator: `\n§\n`. Distinct enough to never collide with content. */
60
62
  exports.ENTRY_SEPARATOR = '\n§\n';
61
63
  class MemoryManager {
62
- constructor(paths) {
63
- this.paths = paths;
64
+ // v4.9.0 Slice 11 — constructor now accepts either the legacy
65
+ // `AidenPaths` (back-compat) OR an options object with optional
66
+ // `projectRoot`. Both shapes preserve `this.paths` for the rest of
67
+ // the class.
68
+ constructor(opts) {
64
69
  this.name = 'builtin';
65
70
  this.writeQueue = Promise.resolve();
66
71
  /**
@@ -70,6 +75,15 @@ class MemoryManager {
70
75
  * fire listeners — preserves the "stale snapshot stays clean" invariant.
71
76
  */
72
77
  this.mutationListeners = new Set();
78
+ if (opts.paths !== undefined) {
79
+ const o = opts;
80
+ this.paths = o.paths;
81
+ this.projectRoot = o.projectRoot ?? null;
82
+ }
83
+ else {
84
+ this.paths = opts;
85
+ this.projectRoot = null;
86
+ }
73
87
  }
74
88
  /**
75
89
  * Phase 16d: subscribe to successful memory mutations. Returns an
@@ -102,12 +116,28 @@ class MemoryManager {
102
116
  async loadSnapshot() {
103
117
  const memoryMd = await readFileOrEmpty(this.paths.memoryMd);
104
118
  const userMd = await readFileOrEmpty(this.paths.userMd);
105
- return {
106
- memoryMd,
107
- userMd,
108
- loadedAt: Date.now(),
109
- isEmpty: memoryMd.trim().length === 0 && userMd.trim().length === 0,
110
- };
119
+ // v4.9.0 Slice 11 — collect every registered namespace's content
120
+ // into the generalized `files` map. Legacy `memoryMd` / `userMd`
121
+ // fields stay populated for back-compat with the 9 v4 consumers
122
+ // that read them directly (memoryGuard, aidenAgent listeners, ...).
123
+ const files = {};
124
+ let anyContent = memoryMd.trim().length > 0 || userMd.trim().length > 0;
125
+ for (const ns of (0, namespaceRegistry_1.listNamespaces)()) {
126
+ let p;
127
+ try {
128
+ p = ns.resolve(this.paths, this.projectRoot);
129
+ }
130
+ catch {
131
+ continue; /* requiresProject + no root — silently skip */
132
+ }
133
+ const content = await readFileOrEmpty(p);
134
+ files[ns.name] = {
135
+ content, charCount: content.length, charLimit: ns.charLimit, path: p,
136
+ };
137
+ if (content.trim().length > 0)
138
+ anyContent = true;
139
+ }
140
+ return { memoryMd, userMd, loadedAt: Date.now(), isEmpty: !anyContent, files };
111
141
  }
112
142
  add(file, content) {
113
143
  return this.serialised(async () => {
@@ -224,8 +254,21 @@ class MemoryManager {
224
254
  });
225
255
  }
226
256
  // ── Internals ────────────────────────────────────────────────────────
257
+ // v4.9.0 Slice 11 — resolve via namespace registry. Legacy callers
258
+ // passing 'memory' / 'user' hit the built-in resolvers; new namespaces
259
+ // (project, future plugin namespaces) flow through the same path.
227
260
  pathFor(file) {
228
- return file === 'user' ? this.paths.userMd : this.paths.memoryMd;
261
+ if (!(0, namespaceRegistry_1.hasNamespace)(file)) {
262
+ throw new Error(`unknown memory namespace '${file}'`);
263
+ }
264
+ return (0, namespaceRegistry_1.getNamespace)(file).resolve(this.paths, this.projectRoot);
265
+ }
266
+ /**
267
+ * v4.9.0 Slice 11 — public char-limit lookup for any namespace.
268
+ * Replaces the legacy file-local `limitFor` switch with a registry hit.
269
+ */
270
+ charLimitFor(namespace) {
271
+ return (0, namespaceRegistry_1.getNamespace)(namespace).charLimit;
229
272
  }
230
273
  /**
231
274
  * Serialise mutations so two concurrent add()s can't collapse into a
@@ -238,8 +281,12 @@ class MemoryManager {
238
281
  }
239
282
  }
240
283
  exports.MemoryManager = MemoryManager;
284
+ // v4.9.0 Slice 11 — registry-aware. Legacy two-namespace callers
285
+ // (memoryGuard, tools) hit this via the existing add/replace/remove
286
+ // paths; the constants above stay exported for any consumer still
287
+ // importing them directly.
241
288
  function limitFor(file) {
242
- return file === 'user' ? exports.USER_CHAR_LIMIT : exports.MEMORY_CHAR_LIMIT;
289
+ return (0, namespaceRegistry_1.getNamespace)(file).charLimit;
243
290
  }
244
291
  async function readFileOrEmpty(p) {
245
292
  try {
@@ -102,6 +102,8 @@ function resolveAidenPaths(opts = {}) {
102
102
  skinsDir: node_path_1.default.join(root, 'skins'),
103
103
  recentCommandsFile: node_path_1.default.join(root, '.recent-commands.json'),
104
104
  sessionsDir: node_path_1.default.join(root, 'sessions'),
105
+ distillationsDir: node_path_1.default.join(root, 'distillations'),
106
+ memoryBackupsDir: node_path_1.default.join(root, 'memory-backups'),
105
107
  pluginsDir: node_path_1.default.join(root, 'plugins'),
106
108
  logsDir: node_path_1.default.join(root, 'logs'),
107
109
  bundledManifest: node_path_1.default.join(root, '.bundled_manifest'),
@@ -166,6 +166,12 @@ const UI_EVENTS_GUIDANCE = [
166
166
  'Markdown text in your reply is for explanation, not status. Status goes',
167
167
  'through events. Skip events entirely on single-shot queries that aren\'t',
168
168
  'multi-step work.',
169
+ '',
170
+ '## Comparison formatting',
171
+ '',
172
+ 'For comparison requests, prefer sectioned lists or narrow tables (3 cols max).',
173
+ 'Wide tables (4+ columns or cells over ~30 chars) render imperfectly in the',
174
+ 'CLI grid — break long content into sections with headers + bullets instead.',
169
175
  ].join('\n');
170
176
  /**
171
177
  * Llama-3.3-specific tool-call format guard. Adapter-side recovery picks
@@ -29,6 +29,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.spawnSubAgent = spawnSubAgent;
30
30
  const node_crypto_1 = require("node:crypto");
31
31
  const childBuilder_1 = require("./childBuilder");
32
+ // v4.9.0 Slice 7 — fork ExecutionContext into the child agent.
33
+ const identity_1 = require("../identity");
32
34
  const factory_1 = require("../logger/factory");
33
35
  // ── Constants ─────────────────────────────────────────────────────────────
34
36
  const DEFAULT_TIMEOUT_MS = 600000;
@@ -189,14 +191,25 @@ async function spawnSubAgent(spec, deps, ctx) {
189
191
  let apiCalls = 0;
190
192
  let tokensIn = 0;
191
193
  let tokensOut = 0;
194
+ // v4.9.0 Slice 7 — fork an ExecutionContext for the child so its
195
+ // tool/LLM spans chain off the parent's spanId. AsyncLocalStorage
196
+ // is in-process; the child runs inside `runWithContext(childCtx, ...)`
197
+ // so any `currentContext()` reads during the child's runConversation
198
+ // see the child's chain (same traceId, fresh spanId, parentSpanId =
199
+ // parent's spanId). Out-of-context callers (legacy paths) leave
200
+ // the call exactly as it was pre-Slice-7.
201
+ const parentCtx = (0, identity_1.currentContext)();
202
+ const runChild = async () => agentBundle.agent.runConversation(agentBundle.history, {
203
+ signal: childCtrl.signal,
204
+ // v4.8.0 Phase 2.2 — uiOnly events from a subagent are
205
+ // dropped. Subagents have no chat surface; the parent
206
+ // assembles their summary. Stub stays a no-op forever.
207
+ onUiEvent: () => { },
208
+ });
192
209
  try {
193
- const result = await agentBundle.agent.runConversation(agentBundle.history, {
194
- signal: childCtrl.signal,
195
- // v4.8.0 Phase 2.2 — uiOnly events from a subagent are
196
- // dropped. Subagents have no chat surface; the parent
197
- // assembles their summary. Stub stays a no-op forever.
198
- onUiEvent: () => { },
199
- });
210
+ const result = await (parentCtx
211
+ ? (0, identity_1.runWithContext)((0, identity_1.childSpan)({ ...parentCtx, source: 'subagent' }), runChild)
212
+ : runChild());
200
213
  apiCalls = result.turnCount; // one provider call per turn
201
214
  tokensIn = result.totalUsage.inputTokens;
202
215
  tokensOut = result.totalUsage.outputTokens;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/theme/bundledThemes.ts — v4.9.0 Slice 1b.
10
+ *
11
+ * Locates the bundled theme YAML files. Themes live at `themes/*.yaml`
12
+ * at the repo root and are included in the published npm artifact via
13
+ * `package.json#files`. This module resolves the on-disk path for
14
+ * each theme name and exposes a tiny API that the `/theme list` and
15
+ * `/theme set` slash commands consume without needing to know the
16
+ * fs layout.
17
+ *
18
+ * Defense-in-depth: if a corrupted install is missing the `themes/`
19
+ * directory entirely, callers fall through gracefully — `getYaml()`
20
+ * returns null, `/theme list` shows zero bundled themes (but still
21
+ * lists any user themes from `~/.aiden/themes/`).
22
+ */
23
+ var __importDefault = (this && this.__importDefault) || function (mod) {
24
+ return (mod && mod.__esModule) ? mod : { "default": mod };
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.BUNDLED_NAMES = void 0;
28
+ exports.bundledYamlPath = bundledYamlPath;
29
+ exports.getYaml = getYaml;
30
+ exports.listBundled = listBundled;
31
+ exports.isBundled = isBundled;
32
+ exports._resetForTests = _resetForTests;
33
+ const node_fs_1 = require("node:fs");
34
+ const node_path_1 = __importDefault(require("node:path"));
35
+ /** Bundled theme names, sorted to give `/theme list` a stable order. */
36
+ exports.BUNDLED_NAMES = ['default', 'monochrome', 'light', 'tokyo-night', 'dracula'];
37
+ /**
38
+ * Walk up from `__dirname` looking for the repo root that holds the
39
+ * `themes/` directory. Works for:
40
+ * - tsc tree (dist/core/v4/theme/bundledThemes.js → walk 3 up)
41
+ * - esbuild (dist-bundle/cli.js → walk 1 up)
42
+ * - source (core/v4/theme/bundledThemes.ts → walk 3 up via tsx)
43
+ * - npm install (node_modules/aiden-runtime/dist/... → walk to the
44
+ * aiden-runtime root)
45
+ *
46
+ * Returns null when no `themes/` directory is found within 6 ancestors
47
+ * (corrupted install). Callers treat null as "no bundled themes
48
+ * available" and degrade gracefully.
49
+ */
50
+ function findThemesDir() {
51
+ let dir = __dirname;
52
+ for (let i = 0; i < 6; i += 1) {
53
+ const candidate = node_path_1.default.join(dir, 'themes');
54
+ if ((0, node_fs_1.existsSync)(candidate) && (0, node_fs_1.existsSync)(node_path_1.default.join(candidate, 'default.yaml'))) {
55
+ return candidate;
56
+ }
57
+ const parent = node_path_1.default.dirname(dir);
58
+ if (parent === dir)
59
+ break;
60
+ dir = parent;
61
+ }
62
+ return null;
63
+ }
64
+ let cachedThemesDir;
65
+ function themesDir() {
66
+ if (cachedThemesDir === undefined)
67
+ cachedThemesDir = findThemesDir();
68
+ return cachedThemesDir;
69
+ }
70
+ /** Absolute path to a bundled YAML, or null when the themes/ dir is missing. */
71
+ function bundledYamlPath(name) {
72
+ const dir = themesDir();
73
+ if (!dir)
74
+ return null;
75
+ return node_path_1.default.join(dir, `${name}.yaml`);
76
+ }
77
+ /** Read the YAML for a bundled theme. Returns null on missing-file. */
78
+ function getYaml(name) {
79
+ const file = bundledYamlPath(name);
80
+ if (!file || !(0, node_fs_1.existsSync)(file))
81
+ return null;
82
+ try {
83
+ return (0, node_fs_1.readFileSync)(file, 'utf8');
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ const DESCRIPTIONS = {
90
+ default: "Aiden's signature brand-orange theme on a dark terminal.",
91
+ monochrome: 'Pure greyscale. Semantic accents retained for error/success readability.',
92
+ light: 'Light terminal. Dark text on light background. Brand orange accent.',
93
+ 'tokyo-night': 'Inspired by Tokyo Night. Cool nocturnal palette.',
94
+ dracula: 'Inspired by Dracula. High-contrast dark with vivid accents.',
95
+ };
96
+ /** Enumerate bundled themes. Filters out any that fail to resolve on disk. */
97
+ function listBundled() {
98
+ return exports.BUNDLED_NAMES
99
+ .filter((n) => bundledYamlPath(n) !== null)
100
+ .map((n) => ({ name: n, description: DESCRIPTIONS[n] }));
101
+ }
102
+ function isBundled(name) {
103
+ return exports.BUNDLED_NAMES.includes(name);
104
+ }
105
+ /** Test-only reset of the themes-dir cache. */
106
+ function _resetForTests() { cachedThemesDir = undefined; }
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/theme/themeLoader.ts — v4.9.0 Slice 1a.
10
+ *
11
+ * YAML parser + validator for user theme files. Permissive by design:
12
+ * a malformed file or invalid field warns and falls back per-field
13
+ * rather than rejecting the entire theme. Aiden must keep running
14
+ * with a sensible visual even if the user's theme.yaml has typos.
15
+ *
16
+ * Wire format (hex strings throughout — see also the legacy
17
+ * `skins/*.yaml` format which uses RGB tuples; the two systems
18
+ * deliberately use distinct formats so users can tell which file
19
+ * goes with which slash command):
20
+ *
21
+ * name: "my-theme"
22
+ * description: "..."
23
+ * inherits: null # or a bundled theme name
24
+ * colors:
25
+ * brand: { primary: "#FF6B35", muted: "#7a3119" }
26
+ * # ... etc
27
+ * glyphs:
28
+ * panel: { bar: "│" }
29
+ * trail: { gutter: "┊" }
30
+ * # ... etc
31
+ */
32
+ var __importDefault = (this && this.__importDefault) || function (mod) {
33
+ return (mod && mod.__esModule) ? mod : { "default": mod };
34
+ };
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseThemeYaml = parseThemeYaml;
37
+ exports.loadThemeFile = loadThemeFile;
38
+ const node_fs_1 = require("node:fs");
39
+ const js_yaml_1 = __importDefault(require("js-yaml"));
40
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
41
+ /**
42
+ * Parse a YAML string into a ParsedTheme. Returns `parsed: null`
43
+ * only on top-level YAML parse failure (in which case the caller
44
+ * should keep the current theme). Otherwise returns the best-effort
45
+ * parse with per-field warnings; bad fields are silently dropped
46
+ * from the override maps so the baseline shows through.
47
+ */
48
+ function parseThemeYaml(text) {
49
+ const warnings = [];
50
+ let doc;
51
+ try {
52
+ doc = js_yaml_1.default.load(text);
53
+ }
54
+ catch (err) {
55
+ warnings.push(`YAML parse error: ${err.message}`);
56
+ return { parsed: null, warnings };
57
+ }
58
+ // v4.9.0 Slice 1a hotfix — `yaml.load('')` returns `undefined` for
59
+ // an empty file. That's a benign transient state — the user just
60
+ // created theme.yaml via /theme edit and hasn't typed content yet,
61
+ // OR they cleared the file before retyping. Don't surface a warning
62
+ // for that case; just signal "no theme parsed" and let the caller
63
+ // keep the current theme active.
64
+ if (doc === undefined || doc === null) {
65
+ return { parsed: null, warnings };
66
+ }
67
+ if (typeof doc !== 'object') {
68
+ warnings.push('Theme root must be a mapping; got ' + typeof doc);
69
+ return { parsed: null, warnings };
70
+ }
71
+ const root = doc;
72
+ const name = typeof root.name === 'string' && root.name.trim().length > 0
73
+ ? root.name.trim()
74
+ : 'custom';
75
+ const description = typeof root.description === 'string'
76
+ ? root.description
77
+ : undefined;
78
+ const colorOverrides = {};
79
+ collectStringPaths(root.colors, 'colors', colorOverrides, warnings, (value, path) => {
80
+ if (!HEX_RE.test(value)) {
81
+ warnings.push(`Invalid hex at ${path}: "${value}" (expected #RGB or #RRGGBB); falling back to default.`);
82
+ return null;
83
+ }
84
+ return value;
85
+ });
86
+ const glyphOverrides = {};
87
+ collectStringPaths(root.glyphs, 'glyphs', glyphOverrides, warnings, (value /* , path */) => {
88
+ // Glyphs are free-form strings — no validation beyond "is a string".
89
+ return value;
90
+ });
91
+ // The dotted paths returned by collectStringPaths include the
92
+ // `colors.` / `glyphs.` prefix. Strip those so the registry can
93
+ // write directly into the `colors` / `glyphs` singletons.
94
+ const colorOverridesStripped = {};
95
+ for (const [k, v] of Object.entries(colorOverrides)) {
96
+ colorOverridesStripped[k.replace(/^colors\./, '')] = v;
97
+ }
98
+ const glyphOverridesStripped = {};
99
+ for (const [k, v] of Object.entries(glyphOverrides)) {
100
+ glyphOverridesStripped[k.replace(/^glyphs\./, '')] = v;
101
+ }
102
+ return {
103
+ parsed: {
104
+ name,
105
+ description,
106
+ colorOverrides: colorOverridesStripped,
107
+ glyphOverrides: glyphOverridesStripped,
108
+ },
109
+ warnings,
110
+ };
111
+ }
112
+ /**
113
+ * Load + parse a theme file from disk. Returns `{ parsed: null }`
114
+ * on missing-file or read-error so the caller can degrade gracefully.
115
+ */
116
+ function loadThemeFile(filepath) {
117
+ let text;
118
+ try {
119
+ text = (0, node_fs_1.readFileSync)(filepath, 'utf8');
120
+ }
121
+ catch (err) {
122
+ return {
123
+ parsed: null,
124
+ warnings: [`Could not read theme file ${filepath}: ${err.message}`],
125
+ };
126
+ }
127
+ return parseThemeYaml(text);
128
+ }
129
+ /**
130
+ * Walk `node` recursively. For every string leaf, call `transform`
131
+ * to optionally validate / normalise it. If `transform` returns null
132
+ * the leaf is dropped (validation failure). Non-string non-object
133
+ * leaves trigger a warning and are dropped.
134
+ */
135
+ function collectStringPaths(node, pathSoFar, out, warnings, transform) {
136
+ if (node === undefined || node === null)
137
+ return;
138
+ if (typeof node !== 'object') {
139
+ warnings.push(`Expected object at ${pathSoFar}; got ${typeof node}`);
140
+ return;
141
+ }
142
+ if (Array.isArray(node)) {
143
+ warnings.push(`Array not supported at ${pathSoFar}`);
144
+ return;
145
+ }
146
+ for (const [k, v] of Object.entries(node)) {
147
+ const childPath = `${pathSoFar}.${k}`;
148
+ if (typeof v === 'string') {
149
+ const value = transform(v, childPath);
150
+ if (value !== null)
151
+ out[childPath] = value;
152
+ }
153
+ else if (typeof v === 'object' && v !== null) {
154
+ collectStringPaths(v, childPath, out, warnings, transform);
155
+ }
156
+ else {
157
+ warnings.push(`Unsupported leaf type at ${childPath}: ${typeof v}`);
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/theme/themeRegistry.ts — v4.9.0 Slice 1a.
10
+ *
11
+ * Singleton holding the active theme's name + path. The actual token
12
+ * values live on the mutable `colors` / `glyphs` exports of
13
+ * `cli/v4/design/tokens.ts`; this registry orchestrates applying a
14
+ * parsed theme onto those singletons and notifying subscribers when
15
+ * a hot-reload happens.
16
+ *
17
+ * Subscribers are anything that wants to be notified when the theme
18
+ * changes (e.g. the SkinEngine, which caches resolved RGB tuples).
19
+ *
20
+ * Why a separate module: keeps the theme-loading state machine out of
21
+ * `tokens.ts` (which is pure data) and out of `cli/v4/` (so core code
22
+ * with no UI dependency can still consult the registry without a
23
+ * core → cli import).
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.BASELINE_GLYPHS = exports.BASELINE_COLORS = void 0;
27
+ exports.applyTheme = applyTheme;
28
+ exports.resetToDefault = resetToDefault;
29
+ exports.getCurrentName = getCurrentName;
30
+ exports.getActivePath = getActivePath;
31
+ exports.subscribe = subscribe;
32
+ const tokens_1 = require("../../../cli/v4/design/tokens");
33
+ Object.defineProperty(exports, "BASELINE_COLORS", { enumerable: true, get: function () { return tokens_1.BASELINE_COLORS; } });
34
+ Object.defineProperty(exports, "BASELINE_GLYPHS", { enumerable: true, get: function () { return tokens_1.BASELINE_GLYPHS; } });
35
+ let currentName = 'default';
36
+ let activePath = null;
37
+ const listeners = new Set();
38
+ /**
39
+ * Apply a parsed theme on top of the baseline. Restores baseline
40
+ * first (so successive `applyTheme` calls don't accumulate stale
41
+ * overrides), then walks each override path and writes the value
42
+ * into the live `colors` / `glyphs` singletons.
43
+ */
44
+ function applyTheme(parsed, path = null) {
45
+ // Reset to baseline first — guarantees idempotency.
46
+ (0, tokens_1._restoreBaselineForTokens)();
47
+ for (const [dotted, value] of Object.entries(parsed.colorOverrides)) {
48
+ writePath(tokens_1.colors, dotted, value);
49
+ }
50
+ for (const [dotted, value] of Object.entries(parsed.glyphOverrides)) {
51
+ writePath(tokens_1.glyphs, dotted, value);
52
+ }
53
+ currentName = parsed.name || 'custom';
54
+ activePath = path;
55
+ notify();
56
+ }
57
+ /** Restore baseline token values and clear active theme metadata. */
58
+ function resetToDefault() {
59
+ (0, tokens_1._restoreBaselineForTokens)();
60
+ currentName = 'default';
61
+ activePath = null;
62
+ notify();
63
+ }
64
+ function getCurrentName() { return currentName; }
65
+ function getActivePath() { return activePath; }
66
+ /** Subscribe; returns an unsubscribe fn. */
67
+ function subscribe(fn) {
68
+ listeners.add(fn);
69
+ return () => { listeners.delete(fn); };
70
+ }
71
+ function notify() {
72
+ const snap = { name: currentName, activePath };
73
+ for (const fn of listeners) {
74
+ try {
75
+ fn(snap);
76
+ }
77
+ catch { /* listener crash must not break registry */ }
78
+ }
79
+ }
80
+ /**
81
+ * Write a value into a nested object via a dotted path. Creates
82
+ * intermediate keys as needed (won't happen with validated themes
83
+ * but defensive against schema drift). Last segment is the leaf
84
+ * write. Pure mutation on the input root.
85
+ */
86
+ function writePath(root, dotted, value) {
87
+ const segments = dotted.split('.');
88
+ let node = root;
89
+ for (let i = 0; i < segments.length - 1; i += 1) {
90
+ const seg = segments[i];
91
+ if (typeof node[seg] !== 'object' || node[seg] === null) {
92
+ node[seg] = {};
93
+ }
94
+ node = node[seg];
95
+ }
96
+ node[segments[segments.length - 1]] = value;
97
+ }