@adhdev/daemon-core 0.9.82-rc.165 → 0.9.82-rc.167

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.
@@ -556,6 +556,24 @@ function hasDispatchAfterTerminal(meshId: string, sessionId: string, terminalId:
556
556
  return false;
557
557
  }
558
558
 
559
+ function hasUnterminalDirectDispatchLedgerEntry(meshId: string, sessionId: string): boolean {
560
+ // Some dispatch paths can persist task_dispatched before the direct-dispatch DB row is
561
+ // available. Recover routing from ledger order so coordinator self-targets still emit
562
+ // task_completed and pendingCoordinatorEvents.
563
+ const entries = readLedgerEntries(meshId, { tail: 200 });
564
+ for (let i = entries.length - 1; i >= 0; i--) {
565
+ const entry = entries[i];
566
+ if (entry.sessionId !== sessionId) continue;
567
+ if (entry.kind === 'task_completed' || entry.kind === 'task_failed' || entry.kind === 'task_stalled') {
568
+ return false;
569
+ }
570
+ if (entry.kind === 'task_dispatched' && entry.payload?.source === 'direct') {
571
+ return true;
572
+ }
573
+ }
574
+ return false;
575
+ }
576
+
559
577
  function buildLongGeneratingCompletionReconciliation(args: {
560
578
  meshId: string;
561
579
  nodeId?: string;
@@ -1627,7 +1645,7 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1627
1645
  if (coordinatorMeshId) {
1628
1646
  try {
1629
1647
  const activeDispatches = getActiveDirectDispatches(coordinatorMeshId);
1630
- if (activeDispatches.some(d => d.sessionId === instanceId)) {
1648
+ if (activeDispatches.some(d => d.sessionId === instanceId) || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, instanceId)) {
1631
1649
  meshIdFromDirectDispatch = coordinatorMeshId;
1632
1650
  }
1633
1651
  } catch { /* best-effort */ }
@@ -729,7 +729,7 @@ export class CliProviderInstance implements ProviderInstance {
729
729
  }
730
730
 
731
731
  getSessionModalState(sessionId?: string): SessionModalState {
732
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
732
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
733
733
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
734
734
  const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
735
735
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
@@ -0,0 +1,218 @@
1
+ /**
2
+ * External provider sources — registry + active-selection state.
3
+ *
4
+ * A user can register one or more 3rd-party git URLs as provider sources.
5
+ * Each source clones into ~/.adhdev/external/<source-name>/, namespaced so
6
+ * that two sources can disk-coexist providing the same provider type.
7
+ *
8
+ * When two sources expose the same type, only one is "active" at a time.
9
+ * The active selection is persisted so a user's choice survives daemon
10
+ * restarts. When only one source provides a type, no explicit selection
11
+ * is needed and the loader falls through to that source.
12
+ */
13
+ 'use strict';
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as os from 'node:os';
17
+ import * as path from 'node:path';
18
+
19
+ export interface ExternalSource {
20
+ /** Unique short identifier, e.g. "@vendor-x". User-supplied or derived from the URL. */
21
+ name: string;
22
+ /** Full git URL (https://, git@, …). */
23
+ url: string;
24
+ /** Branch / tag / commit-ish to track. Defaults to `main`. */
25
+ ref: string;
26
+ /** ISO timestamp when first registered. */
27
+ addedAt: string;
28
+ }
29
+
30
+ export interface ExternalSourcesFile {
31
+ /** Schema version — bump if shape changes. */
32
+ schema: 1;
33
+ sources: ExternalSource[];
34
+ }
35
+
36
+ /**
37
+ * Per-type active source selection. Only types reachable from more than
38
+ * one source need an entry — single-source types have no ambiguity.
39
+ *
40
+ * Shape: { active: { "<type>": "<source-name>" } }
41
+ */
42
+ export interface ProvidersActiveFile {
43
+ schema: 1;
44
+ active: Record<string, string>;
45
+ }
46
+
47
+ const SOURCES_FILENAME = 'providers-sources.json';
48
+ const ACTIVE_FILENAME = 'providers-active.json';
49
+
50
+ function adhdevDir(): string {
51
+ return path.join(os.homedir(), '.adhdev');
52
+ }
53
+
54
+ export function externalRoot(): string {
55
+ return path.join(adhdevDir(), 'external');
56
+ }
57
+
58
+ export function sourcesFilePath(): string {
59
+ return path.join(adhdevDir(), SOURCES_FILENAME);
60
+ }
61
+
62
+ export function activeFilePath(): string {
63
+ return path.join(adhdevDir(), ACTIVE_FILENAME);
64
+ }
65
+
66
+ function ensureAdhdevDir(): void {
67
+ const d = adhdevDir();
68
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
69
+ }
70
+
71
+ export function loadExternalSources(): ExternalSourcesFile {
72
+ const p = sourcesFilePath();
73
+ if (!fs.existsSync(p)) return { schema: 1, sources: [] };
74
+ try {
75
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
76
+ if (!raw || typeof raw !== 'object') return { schema: 1, sources: [] };
77
+ const sources = Array.isArray(raw.sources) ? raw.sources.filter(isValidSource) : [];
78
+ return { schema: 1, sources };
79
+ } catch {
80
+ return { schema: 1, sources: [] };
81
+ }
82
+ }
83
+
84
+ export function saveExternalSources(file: ExternalSourcesFile): void {
85
+ ensureAdhdevDir();
86
+ const tmp = sourcesFilePath() + '.tmp';
87
+ fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
88
+ fs.renameSync(tmp, sourcesFilePath());
89
+ }
90
+
91
+ export function loadProvidersActive(): ProvidersActiveFile {
92
+ const p = activeFilePath();
93
+ if (!fs.existsSync(p)) return { schema: 1, active: {} };
94
+ try {
95
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
96
+ if (!raw || typeof raw !== 'object') return { schema: 1, active: {} };
97
+ const active = raw.active && typeof raw.active === 'object' ? raw.active : {};
98
+ return { schema: 1, active };
99
+ } catch {
100
+ return { schema: 1, active: {} };
101
+ }
102
+ }
103
+
104
+ export function saveProvidersActive(file: ProvidersActiveFile): void {
105
+ ensureAdhdevDir();
106
+ const tmp = activeFilePath() + '.tmp';
107
+ fs.writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', 'utf-8');
108
+ fs.renameSync(tmp, activeFilePath());
109
+ }
110
+
111
+ function isValidSource(x: unknown): x is ExternalSource {
112
+ if (!x || typeof x !== 'object') return false;
113
+ const s = x as Record<string, unknown>;
114
+ return typeof s.name === 'string' && s.name.length > 0
115
+ && typeof s.url === 'string' && s.url.length > 0
116
+ && typeof s.ref === 'string' && s.ref.length > 0
117
+ && typeof s.addedAt === 'string';
118
+ }
119
+
120
+ /**
121
+ * Derive a short identifier from a git URL when the user didn't supply one.
122
+ * https://github.com/vendor/extra-providers.git → "@vendor-extra-providers"
123
+ * git@github.com:vendor/extra-providers → "@vendor-extra-providers"
124
+ *
125
+ * Idempotent; safe to call before validation since it produces a string
126
+ * regardless of input shape.
127
+ */
128
+ export function deriveSourceName(url: string): string {
129
+ const m = url.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/);
130
+ if (!m) return '@source';
131
+ const owner = m[1].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
132
+ const repo = m[2].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
133
+ return `@${owner}-${repo}`;
134
+ }
135
+
136
+ /**
137
+ * Return the per-source category/type tree currently on disk under
138
+ * ~/.adhdev/external/<source>/. Used by the conflict detector and by
139
+ * list_provider_sources.
140
+ */
141
+ export interface SourceInventoryEntry {
142
+ sourceName: string;
143
+ /** Map: category → list of provider types. */
144
+ providers: Record<string, string[]>;
145
+ }
146
+
147
+ export function inventoryExternalSources(): SourceInventoryEntry[] {
148
+ const root = externalRoot();
149
+ if (!fs.existsSync(root)) return [];
150
+ const out: SourceInventoryEntry[] = [];
151
+ let entries: fs.Dirent[];
152
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); }
153
+ catch { return []; }
154
+ for (const sourceEntry of entries) {
155
+ if (!sourceEntry.isDirectory()) continue;
156
+ const sourceName = sourceEntry.name;
157
+ const sourceDir = path.join(root, sourceName);
158
+ const providers: Record<string, string[]> = {};
159
+ let categoryEntries: fs.Dirent[];
160
+ try { categoryEntries = fs.readdirSync(sourceDir, { withFileTypes: true }); }
161
+ catch { continue; }
162
+ for (const categoryEntry of categoryEntries) {
163
+ if (!categoryEntry.isDirectory()) continue;
164
+ const category = categoryEntry.name;
165
+ const categoryDir = path.join(sourceDir, category);
166
+ let typeEntries: fs.Dirent[];
167
+ try { typeEntries = fs.readdirSync(categoryDir, { withFileTypes: true }); }
168
+ catch { continue; }
169
+ const types: string[] = [];
170
+ for (const typeEntry of typeEntries) {
171
+ if (!typeEntry.isDirectory()) continue;
172
+ // Only count it as a provider if a manifest file exists
173
+ const typeDir = path.join(categoryDir, typeEntry.name);
174
+ const hasV1 = fs.existsSync(path.join(typeDir, 'provider.v1.json'));
175
+ const hasV0 = fs.existsSync(path.join(typeDir, 'provider.json'));
176
+ if (hasV1 || hasV0) types.push(typeEntry.name);
177
+ }
178
+ if (types.length > 0) providers[category] = types;
179
+ }
180
+ out.push({ sourceName, providers });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * For a given category+type, list every source that currently exposes it.
187
+ * Returns source names in disk-walk order; callers can use the first one
188
+ * when no explicit active selection exists.
189
+ */
190
+ export function sourcesProviding(category: string, type: string): string[] {
191
+ const inventory = inventoryExternalSources();
192
+ return inventory
193
+ .filter(s => (s.providers[category] || []).includes(type))
194
+ .map(s => s.sourceName);
195
+ }
196
+
197
+ /**
198
+ * Resolve which source should be active for a given category+type.
199
+ * - If exactly one source provides it → that source.
200
+ * - If multiple sources provide it → the one named in providers-active.json
201
+ * (when present) or null (ambiguous; loader should warn and pick
202
+ * the first deterministically so daemon doesn't refuse to boot).
203
+ * - If none → null.
204
+ */
205
+ export function resolveActiveSource(
206
+ category: string,
207
+ type: string,
208
+ activeFile?: ProvidersActiveFile,
209
+ ): { source: string | null; ambiguous: boolean; candidates: string[] } {
210
+ const candidates = sourcesProviding(category, type);
211
+ if (candidates.length === 0) return { source: null, ambiguous: false, candidates };
212
+ if (candidates.length === 1) return { source: candidates[0], ambiguous: false, candidates };
213
+ const explicit = (activeFile ?? loadProvidersActive()).active[type];
214
+ if (explicit && candidates.includes(explicit)) {
215
+ return { source: explicit, ambiguous: false, candidates };
216
+ }
217
+ return { source: candidates[0], ambiguous: true, candidates };
218
+ }
@@ -30,6 +30,10 @@ import type {
30
30
  ResolvedProvider,
31
31
  } from './contracts.js';
32
32
  import { validateProviderDefinition } from './provider-schema.js';
33
+ import {
34
+ loadProvidersActive,
35
+ resolveActiveSource,
36
+ } from './external-sources.js';
33
37
  import type { ProviderSourceMode } from '../config/config.js';
34
38
  import type { ProviderSourceConfigSnapshot, ProviderUserDirSource } from '../config/provider-source-config.js';
35
39
 
@@ -215,6 +219,33 @@ export class ProviderLoader {
215
219
  sourceMode: options?.sourceMode,
216
220
  disableUpstream: options?.disableUpstream,
217
221
  });
222
+
223
+ // One-time migration: ~/.adhdev/marketplace → ~/.adhdev/external.
224
+ // The directory was renamed when the "marketplace" install model was
225
+ // dropped in favour of explicit external git sources. Best-effort:
226
+ // if the rename fails we leave both dirs in place and log so the user
227
+ // can investigate.
228
+ this.migrateMarketplaceDirToExternal();
229
+ }
230
+
231
+ private migrateMarketplaceDirToExternal(): void {
232
+ try {
233
+ const home = os.homedir();
234
+ const oldDir = path.join(home, '.adhdev', 'marketplace');
235
+ const newDir = path.join(home, '.adhdev', 'external');
236
+ if (!fs.existsSync(oldDir)) return;
237
+ if (fs.existsSync(newDir)) {
238
+ // Both exist — don't merge. Leave old in place; surface in logs so
239
+ // the user can decide what to keep. Loader still loads from
240
+ // external/ only, so old marketplace/ becomes inert.
241
+ this.log(`Migration skipped: both ~/.adhdev/marketplace and ~/.adhdev/external exist (marketplace dir is now inert and can be removed manually).`);
242
+ return;
243
+ }
244
+ fs.renameSync(oldDir, newDir);
245
+ this.log(`Migrated ~/.adhdev/marketplace → ~/.adhdev/external (one-time rename after provider source-layer cleanup).`);
246
+ } catch (e: any) {
247
+ this.log(`Marketplace→external migration failed: ${e?.message || e}`);
248
+ }
218
249
  }
219
250
 
220
251
  private log(msg: string): void {
@@ -246,12 +277,12 @@ export class ProviderLoader {
246
277
  * Highest-priority editable overrides come first.
247
278
  */
248
279
  getProviderRoots(): string[] {
249
- // Order matters: user customs > marketplace installs > upstream auto-sync.
250
- // findProviderDirInternal walks this list in order to locate the provider
251
- // dir containing the scripts/, so marketplace must be included here even
252
- // though loadAll() also reads it directly.
253
- const marketplaceDir = path.join(os.homedir(), '.adhdev', 'marketplace');
254
- return [this.userDir, marketplaceDir, this.upstreamDir];
280
+ // Order matters: user customs > external (3rd-party sources) > upstream
281
+ // (official auto-sync). findProviderDirInternal walks this list in order
282
+ // to locate the provider dir containing the scripts/, so external must
283
+ // be included here even though loadAll() also reads it directly.
284
+ const externalDir = path.join(os.homedir(), '.adhdev', 'external');
285
+ return [this.userDir, externalDir, this.upstreamDir];
255
286
  }
256
287
 
257
288
  getSourceConfig(): ProviderSourceConfigSnapshot {
@@ -343,16 +374,19 @@ export class ProviderLoader {
343
374
 
344
375
  /**
345
376
  * Load all providers (3-tier priority)
346
- * 1. .upstream/ (GitHub auto-download — primary source)
347
- * 2. User custom (~/.adhdev/providers/ excluding .upstream)
348
- * User custom always wins (highest priority).
377
+ * 1. ~/.adhdev/providers/.upstream/ official git, auto-synced
378
+ * 2. ~/.adhdev/external/ 3rd-party git sources, user-added,
379
+ * bundled providers may include arbitrary JS (untrusted by default)
380
+ * 3. ~/.adhdev/providers/ (excluding .upstream) — user-authored customs,
381
+ * always wins
382
+ * Highest priority listed last (overwrites earlier loads).
349
383
  * If .upstream/ is empty, call fetchLatest() before loadAll().
350
384
  */
351
385
  loadAll(): void {
352
386
  this.providers.clear();
353
387
  this.providerAvailability.clear();
354
388
 
355
- // 1. Load upstream (GitHub auto-download — primary source)
389
+ // 1. Load upstream (GitHub auto-download — primary official source)
356
390
  let upstreamCount = 0;
357
391
  if (!this.disableUpstream && fs.existsSync(this.upstreamDir)) {
358
392
  upstreamCount = this.loadDir(this.upstreamDir);
@@ -363,15 +397,86 @@ export class ProviderLoader {
363
397
  this.log('Upstream loading disabled (sourceMode=no-upstream)');
364
398
  }
365
399
 
366
- // 2. Load marketplace installs from ~/.adhdev/marketplace/ (overrides upstream,
367
- // but is itself overridden by user customs in step 3). These are providers the
368
- // user explicitly installed via the Marketplace UI. They are NOT touched by
369
- // upstream sync.
370
- const marketplaceDir = path.join(os.homedir(), '.adhdev', 'marketplace');
371
- if (fs.existsSync(marketplaceDir)) {
372
- const marketplaceCount = this.loadDir(marketplaceDir);
373
- if (marketplaceCount > 0) {
374
- this.log(`Loaded ${marketplaceCount} marketplace-installed providers`);
400
+ // 2. Load external providers from ~/.adhdev/external/<source-name>/
401
+ // (3rd-party git sources). Overrides upstream but is itself overridden
402
+ // by user customs in step 3.
403
+ //
404
+ // Each registered source is a separate subdirectory so two sources can
405
+ // both expose the same provider type without overwriting each other.
406
+ // When more than one source provides the same type, providers-active.json
407
+ // chooses the active one; without an explicit choice we deterministically
408
+ // pick the first in disk-walk order and log the ambiguity so the user
409
+ // can resolve it from the dashboard.
410
+ //
411
+ // Any non-spec manifest (tui block / overrides / scriptDir) coming from
412
+ // an external source runs JavaScript the daemon hasn't audited, so
413
+ // dashboards must surface an "untrusted source" badge before letting
414
+ // the user enable them.
415
+ const externalDir = path.join(os.homedir(), '.adhdev', 'external');
416
+ if (fs.existsSync(externalDir)) {
417
+ // Legacy layout (pre-source-namespace): manifests sit directly at
418
+ // external/<category>/<type>/. Detect by presence of category dirs at
419
+ // the root and migrate inline by treating the whole tree as a single
420
+ // implicit source. Loader behavior unchanged for legacy callers.
421
+ const rootEntries = (() => {
422
+ try { return fs.readdirSync(externalDir, { withFileTypes: true }); }
423
+ catch { return [] as fs.Dirent[]; }
424
+ })();
425
+ const KNOWN_CATEGORIES = new Set(['cli', 'ide', 'extension', 'acp']);
426
+ const looksLegacy = rootEntries.some(e => e.isDirectory() && KNOWN_CATEGORIES.has(e.name));
427
+ if (looksLegacy) {
428
+ // Tree shape predates per-source dirs — treat the whole thing as a
429
+ // single anonymous source so existing installs keep working until
430
+ // they're migrated to a real source registration.
431
+ const externalCount = this.loadDir(externalDir);
432
+ if (externalCount > 0) {
433
+ this.log(`Loaded ${externalCount} external providers (legacy unnamed source)`);
434
+ }
435
+ } else {
436
+ // New layout: external/<source-name>/<category>/<type>/…
437
+ const activeFile = loadProvidersActive();
438
+ let totalLoaded = 0;
439
+ const ambiguousTypes: { type: string; chosen: string; candidates: string[] }[] = [];
440
+ // Per-source load, then filter by active-selection: for each type
441
+ // present in more than one source, only the active source's copy
442
+ // is left in this.providers.
443
+ for (const sourceEntry of rootEntries) {
444
+ if (!sourceEntry.isDirectory()) continue;
445
+ const sourceDir = path.join(externalDir, sourceEntry.name);
446
+ const sourceLoaded = this.loadDir(sourceDir);
447
+ if (sourceLoaded > 0) {
448
+ totalLoaded += sourceLoaded;
449
+ this.log(`Loaded ${sourceLoaded} providers from external source "${sourceEntry.name}"`);
450
+ }
451
+ }
452
+ // Resolve ambiguities — when the same type came from multiple
453
+ // sources, the last load wins by default. Replay with the active
454
+ // selection so the user-chosen source ends up winning.
455
+ for (const [type] of this.providers) {
456
+ const prov = this.providers.get(type);
457
+ if (!prov) continue;
458
+ const resolved = resolveActiveSource(prov.category, type, activeFile);
459
+ if (resolved.candidates.length <= 1) continue;
460
+ if (resolved.ambiguous) {
461
+ ambiguousTypes.push({ type, chosen: resolved.source ?? '?', candidates: resolved.candidates });
462
+ }
463
+ if (resolved.source && resolved.source !== '?') {
464
+ const sourceDir = path.join(externalDir, resolved.source);
465
+ // Reload only this source's copy of the conflicting type so it
466
+ // overwrites whatever else won the initial pass.
467
+ const reloadCount = this.loadDir(sourceDir);
468
+ // reloadCount is a sanity check — we expect ≥1
469
+ if (reloadCount === 0) {
470
+ this.log(`Active source "${resolved.source}" no longer provides ${type}`);
471
+ }
472
+ }
473
+ }
474
+ if (totalLoaded > 0) {
475
+ this.log(`Loaded ${totalLoaded} external providers (3rd-party sources)`);
476
+ }
477
+ for (const a of ambiguousTypes) {
478
+ this.log(`Ambiguous provider "${a.type}" — provided by [${a.candidates.join(', ')}], defaulted to "${a.chosen}". Set the active source from the dashboard to silence this warning.`);
479
+ }
375
480
  }
376
481
  }
377
482
 
@@ -2132,14 +2237,40 @@ export class ProviderLoader {
2132
2237
  }
2133
2238
  }
2134
2239
 
2240
+ // Classify trust based on which on-disk layer this manifest
2241
+ // came from + whether it ships JavaScript hooks. The dashboard
2242
+ // uses this to render trust badges; non-spec external manifests
2243
+ // need an explicit user confirm before activation.
2244
+ const externalDirAbs = path.join(os.homedir(), '.adhdev', 'external');
2245
+ const layer: 'user' | 'upstream' | 'external' = d.startsWith(externalDirAbs)
2246
+ ? 'external'
2247
+ : (d.startsWith(this.userDir) && !d.includes('.upstream') ? 'user' : 'upstream');
2248
+ try {
2249
+ const { inspectManifestShape, classifyTrust } =
2250
+ require('./provider-trust.js') as typeof import('./provider-trust.js');
2251
+ const shape = inspectManifestShape(mod as Record<string, unknown>);
2252
+ const trust = classifyTrust(layer, shape);
2253
+ (normalizedProvider as any)._sourceLayer = layer;
2254
+ (normalizedProvider as any)._sourceTrust = trust;
2255
+ (normalizedProvider as any)._manifestShape = shape;
2256
+ // For external-namespaced layouts (external/<source>/…) record
2257
+ // which source the manifest came from so dashboards can name
2258
+ // it in the trust badge.
2259
+ if (layer === 'external') {
2260
+ const rel = path.relative(externalDirAbs, d);
2261
+ const firstSeg = rel.split(path.sep)[0];
2262
+ if (firstSeg && firstSeg !== '..') (normalizedProvider as any)._sourceName = firstSeg;
2263
+ }
2264
+ } catch { /* best-effort — trust is enrichment, not gating */ }
2265
+
2135
2266
  const existed = this.providers.has(normalizedProvider.type);
2136
2267
  this.providers.set(normalizedProvider.type, normalizedProvider);
2137
2268
  count++;
2138
- // Identify source tier for debugging
2139
- const source = d.startsWith(this.userDir) && !d.includes('.upstream')
2140
- ? 'user' : 'upstream';
2269
+ const source = (normalizedProvider as any)._sourceLayer ?? 'upstream';
2141
2270
  const overrideWarning = existed && source === 'user' ? ' ⚠ OVERRIDES upstream' : '';
2142
- this.log(` ${existed ? '🔄' : '✅'} ${normalizedProvider.type} (${normalizedProvider.category}) ${normalizedProvider.name} [${source}]${overrideWarning}`);
2271
+ const sourceName = (normalizedProvider as any)._sourceName;
2272
+ const sourceLabel = sourceName ? `${source}/${sourceName}` : source;
2273
+ this.log(` ${existed ? '🔄' : '✅'} ${normalizedProvider.type} (${normalizedProvider.category}) — ${normalizedProvider.name} [${sourceLabel}]${overrideWarning}`);
2143
2274
  }
2144
2275
  } catch (e) {
2145
2276
  this.log(`⚠ Failed to load ${jsonPath}: ${(e as Error).message}`);
@@ -2151,6 +2282,11 @@ export class ProviderLoader {
2151
2282
  for (const entry of entries) {
2152
2283
  if (!entry.isDirectory()) continue;
2153
2284
  if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
2285
+ // `examples/` is a documentation / scaffold tree (e.g. stub-cli),
2286
+ // not a real provider source. SDK authors copy from here when
2287
+ // writing a new provider; daemon-core tests reference the
2288
+ // manifest by path. Keep it off the dashboard's provider list.
2289
+ if (d === dir && entry.name === 'examples') continue;
2154
2290
  if (excludeDirs && d === dir && excludeDirs.includes(entry.name)) continue;
2155
2291
  scan(path.join(d, entry.name));
2156
2292
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Provider trust classification.
3
+ *
4
+ * Each loaded provider gets a `trust` tag that the daemon and dashboard
5
+ * use to decide what to surface and what to gate behind a confirmation
6
+ * prompt. Trust is a function of *where* the provider came from
7
+ * (upstream / external / userDir) and the *shape of the manifest*
8
+ * (spec-only vs. carries JavaScript hooks).
9
+ *
10
+ * Rationale:
11
+ * - Spec-only manifests are static declarative configuration; the
12
+ * daemon's spec adapter walks the spec but never executes code
13
+ * authored by the provider.
14
+ * - Non-spec manifests ship JavaScript (tui-block builders consumed
15
+ * by the SDK, scriptDir overrides, or explicit `overrides.* .path`
16
+ * bindings). That code runs inside the daemon process with the
17
+ * same privileges as adhdev itself.
18
+ *
19
+ * Source × shape decides the trust label:
20
+ *
21
+ * layer | spec-only | non-spec
22
+ * ---------|---------------------|-----------------------
23
+ * user | user-custom | user-custom
24
+ * upstream | trusted | trusted-with-scripts
25
+ * external | external-safe | external-untrusted
26
+ *
27
+ * Dashboards should always show a badge for `trusted-with-scripts`
28
+ * (info — runs official JS), `external-safe` (info — declarative only,
29
+ * but 3rd-party), and `external-untrusted` (warning — runs JS from a
30
+ * 3rd-party). Activation of `external-untrusted` should require an
31
+ * explicit confirmation step that names the source URL.
32
+ */
33
+ 'use strict';
34
+
35
+ export type ProviderTrust =
36
+ | 'user-custom'
37
+ | 'trusted'
38
+ | 'trusted-with-scripts'
39
+ | 'external-safe'
40
+ | 'external-untrusted';
41
+
42
+ export type ProviderLayer = 'user' | 'upstream' | 'external';
43
+
44
+ export interface ProviderManifestShape {
45
+ /** Has a `tui` block — SDK builders consume it as code paths. */
46
+ hasTui: boolean;
47
+ /** Has a non-empty `overrides` object — JS override paths. */
48
+ hasOverrides: boolean;
49
+ /** compatibility[].scriptDir or defaultScriptDir is set — JS scripts dir. */
50
+ hasScriptDir: boolean;
51
+ }
52
+
53
+ /**
54
+ * Inspect a manifest to decide whether it ships JavaScript hooks.
55
+ * Cheap; pure; safe to call on every provider load.
56
+ */
57
+ export function inspectManifestShape(manifest: Record<string, unknown>): ProviderManifestShape {
58
+ const hasTui = !!manifest.tui && typeof manifest.tui === 'object'
59
+ && Object.keys(manifest.tui as Record<string, unknown>).length > 0;
60
+ const hasOverrides = !!manifest.overrides && typeof manifest.overrides === 'object'
61
+ && !Array.isArray(manifest.overrides)
62
+ && Object.keys(manifest.overrides as Record<string, unknown>).length > 0;
63
+ const compat = Array.isArray(manifest.compatibility) ? manifest.compatibility : [];
64
+ const compatHasScriptDir = compat.some((entry: any) => typeof entry?.scriptDir === 'string');
65
+ const hasScriptDir = compatHasScriptDir || typeof manifest.defaultScriptDir === 'string';
66
+ return { hasTui, hasOverrides, hasScriptDir };
67
+ }
68
+
69
+ /**
70
+ * Classify trust given the layer the provider was loaded from + the
71
+ * manifest's JS-hook footprint.
72
+ */
73
+ export function classifyTrust(
74
+ layer: ProviderLayer,
75
+ shape: ProviderManifestShape,
76
+ ): ProviderTrust {
77
+ const isSpecOnly = !shape.hasTui && !shape.hasOverrides && !shape.hasScriptDir;
78
+ switch (layer) {
79
+ case 'user':
80
+ return 'user-custom';
81
+ case 'upstream':
82
+ return isSpecOnly ? 'trusted' : 'trusted-with-scripts';
83
+ case 'external':
84
+ return isSpecOnly ? 'external-safe' : 'external-untrusted';
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Returns true when activation should require an explicit user confirm.
90
+ * Today only `external-untrusted` qualifies; future trust levels may
91
+ * fold in here.
92
+ */
93
+ export function requiresConfirmation(trust: ProviderTrust): boolean {
94
+ return trust === 'external-untrusted';
95
+ }
96
+
97
+ /**
98
+ * Render a short human-readable rationale for the trust tag. Used by
99
+ * the dashboard's confirmation modal and the provider catalog tooltip.
100
+ */
101
+ export function describeTrust(trust: ProviderTrust): string {
102
+ switch (trust) {
103
+ case 'user-custom':
104
+ return 'Hand-authored in ~/.adhdev/providers/. Runs your own code.';
105
+ case 'trusted':
106
+ return 'Official, declarative-only manifest from the ADHDev registry.';
107
+ case 'trusted-with-scripts':
108
+ return 'Official manifest from the ADHDev registry. Ships JavaScript hooks executed by the daemon.';
109
+ case 'external-safe':
110
+ return 'Manifest from a 3rd-party git source you added. Declarative-only — the daemon never runs JS from this source.';
111
+ case 'external-untrusted':
112
+ return 'Manifest from a 3rd-party git source you added. Ships JavaScript that the daemon will execute. Treat as untrusted code — review the source before enabling.';
113
+ }
114
+ }
@@ -44,6 +44,7 @@ export interface ModalTuiSpec {
44
44
  questionVariants?: ModalQuestionVariant[];
45
45
  buttonPattern: string;
46
46
  buttonFlags?: string;
47
+ buttonLabelGroup?: number;
47
48
  /**
48
49
  * Optional fallback for terminals that render all options on a single line
49
50
  * (e.g. Antigravity feedback survey: `[0] skip [1] yes [2] no [3] still using`).
@@ -136,12 +137,16 @@ function extractButtons(
136
137
  ): string[] {
137
138
  const buttonRe = compile(spec.buttonPattern, spec.buttonFlags ?? 'm');
138
139
  const out: string[] = [];
140
+ const labelGroup = Number.isInteger(spec.buttonLabelGroup) && (spec.buttonLabelGroup ?? 0) > 0
141
+ ? spec.buttonLabelGroup!
142
+ : 1;
139
143
  let i = windowStart;
140
144
  while (i < windowEnd) {
141
145
  const line = lines[i];
142
146
  const m = buttonRe.exec(line);
143
- if (m && m[1]) {
144
- let label = m[1].trim();
147
+ const captured = m?.[labelGroup] ?? (labelGroup === 1 && m && m.length > 2 ? m[m.length - 1] : undefined);
148
+ if (m && captured) {
149
+ let label = captured.trim();
145
150
  // Continuation lines: when enabled, append indented lines below until
146
151
  // the next button or blank.
147
152
  if (spec.continuationLines) {
@@ -143,7 +143,7 @@ export const V1_PRIMITIVE_CATALOG = Object.freeze({
143
143
  ],
144
144
  } as const);
145
145
 
146
- /** Aggregate flat list — for marketplace catalog endpoint. */
146
+ /** Aggregate flat list — for provider catalog endpoints. */
147
147
  export const V1_ALL_PRIMITIVES: ReadonlyArray<string> = Object.freeze(
148
148
  Object.values(V1_PRIMITIVE_CATALOG).flat(),
149
149
  );
@@ -344,7 +344,7 @@ function canonicalize(p: string): string | null {
344
344
  ? nodeFs.realpathSync.native(resolved)
345
345
  : nodeFs.realpathSync(resolved);
346
346
  } catch {
347
- // Root may not exist yet (e.g. marketplace dir created lazily);
347
+ // Root may not exist yet (e.g. external dir created lazily);
348
348
  // we still register the resolved path so future requires from
349
349
  // inside are gated once the directory shows up.
350
350
  return resolved;