dsh-agent-team-ui-testing 0.1.1 → 0.1.3

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -36
  3. package/annotation-capture.js +111 -0
  4. package/browser-safety.js +45 -1
  5. package/client.js +3721 -132
  6. package/context-menu.js +134 -0
  7. package/core/clock.js +88 -0
  8. package/core/config-store.js +566 -0
  9. package/core/config.js +892 -0
  10. package/core/contracts.js +240 -0
  11. package/core/errors.js +156 -0
  12. package/core/evidence.js +141 -0
  13. package/core/geometry.js +217 -0
  14. package/core/identity.js +180 -0
  15. package/core/index.js +24 -0
  16. package/core/p0.js +1101 -0
  17. package/core/readiness.js +175 -0
  18. package/core/registry.js +239 -0
  19. package/core/tab-access.js +1350 -0
  20. package/docs/adapters.md +160 -0
  21. package/docs/architecture.md +44 -9
  22. package/docs/capability-tiers.md +20 -4
  23. package/docs/capture-p0.md +39 -2
  24. package/docs/diagnose-reason-codes.md +157 -0
  25. package/docs/element-picker.md +24 -6
  26. package/docs/getting-started.md +47 -1
  27. package/docs/images/tutorial-01-overview.png +0 -0
  28. package/docs/images/tutorial-02-history-dropdown.png +0 -0
  29. package/docs/images/tutorial-03-pick-element.png +0 -0
  30. package/docs/images/tutorial-04-annotate.png +0 -0
  31. package/docs/index.md +11 -7
  32. package/docs/multi-tab.md +55 -20
  33. package/docs/network-p0.md +2 -2
  34. package/docs/panel-context-menu.md +41 -0
  35. package/docs/t2-structured-gui.md +2 -2
  36. package/docs/t3-vision.md +6 -1
  37. package/docs/t4-dynamic-behavior.md +1 -0
  38. package/docs/tools.md +65 -7
  39. package/docs/tutorial.md +125 -0
  40. package/docs/verification.md +26 -2
  41. package/docs/viewport-control.md +21 -8
  42. package/index.js +3263 -251
  43. package/integration/cross-environment.js +1535 -0
  44. package/integration/diagnose.js +945 -0
  45. package/integration/tab-principal.js +493 -0
  46. package/locale/en.json +1 -1
  47. package/locale/zh.json +1 -1
  48. package/network-p0.js +24 -7
  49. package/package.json +4 -3
  50. package/select-control.js +298 -0
  51. package/skills/ui-testing-common/SKILL.md +54 -0
  52. package/skills/ui-testing-fake/SKILL.md +43 -0
  53. package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
  54. package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
  55. package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
  56. package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
  57. package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
  58. package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
  59. package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
  60. package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
  61. package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
  62. package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
  63. package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
  64. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  65. package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
  66. package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
  67. package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
  68. package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
  69. package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
  70. package/targets/fake/adapter.js +1163 -0
  71. package/targets/fake/fixtures.js +177 -0
  72. package/targets/web/adapter.js +1231 -0
  73. package/targets/web/provider.js +415 -0
  74. package/skills/ui-testing/references/scenarios/annotations.md +0 -87
  75. /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
  76. /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
  77. /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
  78. /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
  79. /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
  80. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
  81. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
  82. /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
  83. /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
  84. /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
  85. /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
  86. /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
  87. /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
  88. /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Durable config store (M0 §4.1).
3
+ *
4
+ * - The directory is supplied by the caller; no machine path is hardcoded.
5
+ * - Load is synchronous and idempotent. A corrupt file is backed up and replaced by defaults
6
+ * instead of crashing the host.
7
+ * - Writes use revision CAS + atomic tmp/rename, mode 0600 (directory 0700).
8
+ * - Without a writable directory the store still loads in memory mode but every write throws.
9
+ */
10
+ import { randomBytes } from 'node:crypto';
11
+ import {
12
+ chmodSync,
13
+ closeSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readFileSync,
18
+ renameSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { DEFAULT_CONFIG, normalizeConfig, resolveSnapshot, validatePatch } from './config.js';
24
+ import { ENVIRONMENTS, ERROR_CODES } from './contracts.js';
25
+ import { AdapterError } from './errors.js';
26
+ import { workspaceKeyOf } from './identity.js';
27
+
28
+ const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
29
+
30
+ function sleepSync(ms) {
31
+ if (ms > 0) Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
32
+ }
33
+
34
+ function clone(value) {
35
+ return structuredClone(value);
36
+ }
37
+
38
+ function isPlainObject(value) {
39
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
40
+ }
41
+
42
+ function requireString(value, field) {
43
+ if (typeof value !== 'string' || value.length === 0) {
44
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, `${field} must be a non-empty string`, {
45
+ details: { field },
46
+ });
47
+ }
48
+ return value;
49
+ }
50
+
51
+ /**
52
+ * Merge one normalized patch entry into an existing environment entry.
53
+ *
54
+ * The environment-level patch must stay a *partial* update: a nested `providers` map is merged per
55
+ * provider id, so writing `{providers:{edge:{enabled:true}}}` never drops the sibling
56
+ * `chrome`/`firefox` switches (a plain `{...existing, ...entry}` spread would).
57
+ */
58
+ function mergeEnvironmentEntry(existing, patchEntry) {
59
+ const base = isPlainObject(existing) ? existing : {};
60
+ const next = { ...base, ...clone(patchEntry) };
61
+ if (isPlainObject(patchEntry?.providers)) {
62
+ const providers = {};
63
+ for (const [provider, entry] of Object.entries(base.providers ?? {})) {
64
+ providers[provider] = isPlainObject(entry) ? { ...entry } : entry;
65
+ }
66
+ for (const [provider, entry] of Object.entries(patchEntry.providers)) {
67
+ providers[provider] = {
68
+ ...(isPlainObject(providers[provider]) ? providers[provider] : {}),
69
+ ...clone(entry),
70
+ };
71
+ }
72
+ next.providers = providers;
73
+ }
74
+ return next;
75
+ }
76
+
77
+ function applyGlobalPatch(config, patch) {
78
+ if (patch.environment !== undefined) config.global.environment = patch.environment;
79
+ if (patch.enabledEnvironments !== undefined) {
80
+ config.global.enabledEnvironments = [...patch.enabledEnvironments];
81
+ }
82
+ if (patch.environments !== undefined) {
83
+ for (const [environment, entry] of Object.entries(patch.environments)) {
84
+ config.global.environments[environment] = mergeEnvironmentEntry(
85
+ config.global.environments[environment],
86
+ entry,
87
+ );
88
+ }
89
+ }
90
+ if (patch.targetId !== undefined) {
91
+ const environment = patch.environment ?? config.global.environment;
92
+ config.global.environments[environment] = mergeEnvironmentEntry(
93
+ config.global.environments[environment],
94
+ { targetId: patch.targetId },
95
+ );
96
+ }
97
+ }
98
+
99
+ function applyWorkspacePatch(config, workspaceKey, patch) {
100
+ const key = requireString(workspaceKey, 'workspaceKey');
101
+ const workspace = config.workspaces[key] ?? {};
102
+ config.workspaces[key] = workspace;
103
+ if (patch.environment !== undefined) workspace.environment = patch.environment;
104
+ if (patch.enabledEnvironments !== undefined) {
105
+ workspace.enabledEnvironments = [...patch.enabledEnvironments];
106
+ }
107
+ if (patch.environments !== undefined) {
108
+ workspace.environments = workspace.environments ?? {};
109
+ for (const [environment, entry] of Object.entries(patch.environments)) {
110
+ workspace.environments[environment] = mergeEnvironmentEntry(
111
+ workspace.environments[environment],
112
+ entry,
113
+ );
114
+ }
115
+ }
116
+ if (patch.targetId !== undefined) {
117
+ const environment = patch.environment ?? workspace.environment ?? config.global.environment;
118
+ workspace.environments = workspace.environments ?? {};
119
+ workspace.environments[environment] = mergeEnvironmentEntry(
120
+ workspace.environments[environment],
121
+ { targetId: patch.targetId },
122
+ );
123
+ }
124
+ }
125
+
126
+ function applySessionPatch(config, agentId, patch) {
127
+ const key = requireString(agentId, 'agentId');
128
+ const session = config.sessions[key] ?? {};
129
+ config.sessions[key] = session;
130
+ if (patch.environment !== undefined) session.environment = patch.environment;
131
+ if (patch.targetId !== undefined) session.targetId = patch.targetId;
132
+ // Session-scoped provider choice only; global/workspace enablement stays a global decision.
133
+ if (patch.webProvider !== undefined) session.webProvider = patch.webProvider;
134
+ }
135
+
136
+ /**
137
+ * Host-owned, revisioned config store.
138
+ */
139
+ export class ConfigStore {
140
+ /**
141
+ * @param {{dir:string, log?:{warn?:Function,info?:Function,error?:Function},
142
+ * now?:Function|number, allowTestEnvironments?:boolean, file?:string,
143
+ * lockFile?:string, lockTimeoutMs?:number, lockPollMs?:number}} input
144
+ */
145
+ constructor({
146
+ dir,
147
+ log = null,
148
+ now = Date.now,
149
+ allowTestEnvironments = false,
150
+ file = 'config.json',
151
+ lockFile = 'config.lock',
152
+ lockTimeoutMs = 2000,
153
+ lockPollMs = 20,
154
+ } = {}) {
155
+ this.dir = requireString(dir, 'dir');
156
+ this.file = requireString(file, 'file');
157
+ this.lockFile = requireString(lockFile, 'lockFile');
158
+ this.path = join(this.dir, this.file);
159
+ this.lockPath = join(this.dir, this.lockFile);
160
+ if (typeof lockTimeoutMs !== 'number' || !Number.isFinite(lockTimeoutMs) || lockTimeoutMs < 0) {
161
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'lockTimeoutMs must be a non-negative number', {
162
+ details: { field: 'lockTimeoutMs' },
163
+ });
164
+ }
165
+ this.lockTimeoutMs = lockTimeoutMs;
166
+ this.lockPollMs = Number.isFinite(lockPollMs) && lockPollMs > 0 ? lockPollMs : 20;
167
+ this.allowTestEnvironments = allowTestEnvironments === true;
168
+ this.log = log;
169
+ this._now = typeof now === 'function' ? now : () => now;
170
+ this._config = null;
171
+ this._loaded = false;
172
+ this._writable = false;
173
+ this._loadError = null;
174
+ this._subscribers = new Set();
175
+ this._disposed = false;
176
+ this._writeSeq = 0;
177
+ this._snapshotCache = new Map();
178
+ }
179
+
180
+ _warn(message, details) {
181
+ try {
182
+ this.log?.warn?.(message, details);
183
+ } catch {
184
+ // Logging must never break config loading.
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Load (once) from disk. Never throws for missing/unreadable/corrupt storage; those cases fall
190
+ * back to defaults and are recorded in `loadError`.
191
+ *
192
+ * @returns {ConfigStore} this
193
+ */
194
+ load() {
195
+ if (this._loaded) return this;
196
+ this._loaded = true;
197
+
198
+ try {
199
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
200
+ try {
201
+ chmodSync(this.dir, 0o700);
202
+ } catch {
203
+ // Best effort: an existing directory may not allow chmod; write failures still surface.
204
+ }
205
+ this._writable = true;
206
+ } catch (error) {
207
+ this._writable = false;
208
+ this._loadError = { reason: 'config-dir-unavailable', message: String(error?.message ?? error) };
209
+ this._warn('config directory unavailable; running in memory mode', { dir: this.dir });
210
+ this._config = clone(DEFAULT_CONFIG);
211
+ return this;
212
+ }
213
+
214
+ let text;
215
+ try {
216
+ text = readFileSync(this.path, 'utf8');
217
+ } catch (error) {
218
+ if (error?.code === 'ENOENT') {
219
+ this._config = clone(DEFAULT_CONFIG);
220
+ return this;
221
+ }
222
+ this._writable = false;
223
+ this._loadError = { reason: 'config-read-failed', message: String(error?.message ?? error) };
224
+ this._warn('config read failed; running in memory mode', { path: this.path });
225
+ this._config = clone(DEFAULT_CONFIG);
226
+ return this;
227
+ }
228
+
229
+ try {
230
+ this._config = normalizeConfig(JSON.parse(text));
231
+ } catch (error) {
232
+ this._config = clone(DEFAULT_CONFIG);
233
+ this._recoverCorrupt(error);
234
+ }
235
+ return this;
236
+ }
237
+
238
+ _recoverCorrupt(error) {
239
+ const at = Number(this._now());
240
+ let backupPath = `${this.path}.corrupt-${Number.isFinite(at) ? at : 0}`;
241
+ let suffix = 0;
242
+ while (existsSync(backupPath)) {
243
+ suffix += 1;
244
+ backupPath = `${this.path}.corrupt-${Number.isFinite(at) ? at : 0}-${suffix}`;
245
+ }
246
+ try {
247
+ renameSync(this.path, backupPath);
248
+ } catch (renameError) {
249
+ backupPath = null;
250
+ this._warn('could not back up corrupt config file', {
251
+ path: this.path,
252
+ message: String(renameError?.message ?? renameError),
253
+ });
254
+ }
255
+ this._loadError = {
256
+ reason: 'config-corrupt',
257
+ message: String(error?.message ?? error),
258
+ backupPath,
259
+ at: Number.isFinite(at) ? at : null,
260
+ };
261
+ this._warn('config file was corrupt; restored defaults', {
262
+ path: this.path,
263
+ backupPath,
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Adopt a newer on-disk revision written by another store instance.
269
+ *
270
+ * CAS against a stale in-memory revision would silently overwrite a concurrent write; refresh
271
+ * first so the conflict is detected. Missing/corrupt files keep the in-memory config.
272
+ */
273
+ _refreshFromDisk() {
274
+ if (!this._writable) return;
275
+ try {
276
+ const disk = normalizeConfig(JSON.parse(readFileSync(this.path, 'utf8')));
277
+ if (disk.revision !== this._config.revision) this._config = disk;
278
+ } catch {
279
+ // Keep the in-memory config; the next successful write persists a fresh revision.
280
+ }
281
+ }
282
+
283
+ /** Current config as a deep copy. Loads on first use. */
284
+ getConfig() {
285
+ this.load();
286
+ return clone(this._config);
287
+ }
288
+
289
+ /** Diagnostic for the last load problem (null when the file was fine). */
290
+ get loadError() {
291
+ return this._loadError === null ? null : clone(this._loadError);
292
+ }
293
+
294
+ /**
295
+ * Apply a CAS-guarded patch and persist it atomically under a cross-process lock.
296
+ *
297
+ * The lock file is `<dir>/config.lock`. Waiting is bounded (default 2000 ms); a timeout throws
298
+ * CONFIG_CONFLICT without touching the config file. Inside the lock the revision is re-read from
299
+ * disk before CAS, so two store instances/processes cannot silently overwrite each other.
300
+ *
301
+ * @param {{expectedRevision?:number, scope:'global'|'workspace'|'session', patch:object,
302
+ * workspaceKey?:string, agentId?:string}} input
303
+ * @returns {{revision:number, config:object}}
304
+ */
305
+ write({ expectedRevision, scope, patch, workspaceKey, agentId } = {}) {
306
+ this.load();
307
+ if (!this._writable) {
308
+ throw new AdapterError(ERROR_CODES.OUTPUT_WRITE_FAILED, 'config directory is not writable; memory mode cannot persist', {
309
+ details: { path: this.path, loadError: this._loadError },
310
+ });
311
+ }
312
+ const normalizedPatch = validatePatch(
313
+ { scope, patch },
314
+ { allowTestEnvironments: this.allowTestEnvironments },
315
+ );
316
+ return this._withLock(() => this._writeLocked({
317
+ expectedRevision,
318
+ scope,
319
+ patch: normalizedPatch,
320
+ workspaceKey,
321
+ agentId,
322
+ }));
323
+ }
324
+
325
+ _writeLocked({ expectedRevision, scope, patch, workspaceKey, agentId }) {
326
+ this._refreshFromDisk();
327
+ if (expectedRevision !== undefined && expectedRevision !== null) {
328
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
329
+ throw new AdapterError(ERROR_CODES.CONFIG_INVALID, 'expectedRevision must be a non-negative integer', {
330
+ details: { field: 'expectedRevision', value: String(expectedRevision) },
331
+ });
332
+ }
333
+ if (expectedRevision !== this._config.revision) {
334
+ throw new AdapterError(ERROR_CODES.CONFIG_CONFLICT, 'config revision conflict', {
335
+ details: { expected: expectedRevision, actual: this._config.revision },
336
+ });
337
+ }
338
+ }
339
+
340
+ const next = clone(this._config);
341
+ if (scope === 'global') {
342
+ applyGlobalPatch(next, patch);
343
+ } else if (scope === 'workspace') {
344
+ applyWorkspacePatch(next, workspaceKey, patch);
345
+ } else {
346
+ applySessionPatch(next, agentId, patch);
347
+ }
348
+ next.revision = this._config.revision + 1;
349
+
350
+ // normalizeConfig is the final structural gate; it may throw CONFIG_INVALID before any I/O.
351
+ const normalizedNext = normalizeConfig(next);
352
+ this._persist(normalizedNext);
353
+ this._config = normalizedNext;
354
+
355
+ const notification = { revision: normalizedNext.revision, config: clone(normalizedNext) };
356
+ for (const subscriber of this._subscribers) {
357
+ try {
358
+ subscriber(notification);
359
+ } catch (error) {
360
+ this._warn('config subscriber threw', { message: String(error?.message ?? error) });
361
+ }
362
+ }
363
+ return { revision: normalizedNext.revision, config: clone(normalizedNext) };
364
+ }
365
+
366
+ _withLock(fn) {
367
+ this._acquireLock();
368
+ try {
369
+ return fn();
370
+ } finally {
371
+ this._releaseLock();
372
+ }
373
+ }
374
+
375
+ _acquireLock() {
376
+ const deadline = Date.now() + this.lockTimeoutMs;
377
+ for (;;) {
378
+ try {
379
+ const fd = openSync(this.lockPath, 'wx', 0o600);
380
+ try {
381
+ writeFileSync(fd, String(process.pid));
382
+ } catch {
383
+ // Lock ownership is the file's existence, not its contents.
384
+ }
385
+ closeSync(fd);
386
+ return;
387
+ } catch (error) {
388
+ if (error?.code !== 'EEXIST') {
389
+ throw new AdapterError(ERROR_CODES.OUTPUT_WRITE_FAILED, 'failed to acquire the config lock', {
390
+ details: { lockPath: this.lockPath, message: String(error?.message ?? error) },
391
+ cause: error,
392
+ });
393
+ }
394
+ const remaining = deadline - Date.now();
395
+ if (remaining <= 0) {
396
+ throw new AdapterError(ERROR_CODES.CONFIG_CONFLICT, 'timed out waiting for the config lock', {
397
+ details: { lockPath: this.lockPath, timeoutMs: this.lockTimeoutMs },
398
+ });
399
+ }
400
+ sleepSync(Math.min(this.lockPollMs, remaining));
401
+ }
402
+ }
403
+ }
404
+
405
+ _releaseLock() {
406
+ try {
407
+ unlinkSync(this.lockPath);
408
+ } catch {
409
+ // Already released (or never created).
410
+ }
411
+ }
412
+
413
+ _persist(config) {
414
+ const tmp = join(
415
+ this.dir,
416
+ `.${this.file}.${process.pid}.${++this._writeSeq}.${randomBytes(4).toString('hex')}.tmp`,
417
+ );
418
+ const text = `${JSON.stringify(config, null, 2)}\n`;
419
+ try {
420
+ writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600 });
421
+ try {
422
+ chmodSync(tmp, 0o600);
423
+ } catch {
424
+ // mode from writeFileSync is already 0600 for a newly created file.
425
+ }
426
+ renameSync(tmp, this.path);
427
+ } catch (error) {
428
+ try {
429
+ unlinkSync(tmp);
430
+ } catch {
431
+ // Nothing to clean up.
432
+ }
433
+ throw new AdapterError(ERROR_CODES.OUTPUT_WRITE_FAILED, 'failed to persist config atomically', {
434
+ details: { path: this.path, message: String(error?.message ?? error) },
435
+ cause: error,
436
+ });
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Convenience wrapper over `write` for selecting an environment/target/provider.
442
+ *
443
+ * Workspace scope accepts either an explicit `workspaceKey` or `{agent, cwd, workspaceId}` and
444
+ * derives the key through `workspaceKeyOf`, so config buckets and runtime entries cannot drift.
445
+ *
446
+ * `webProvider` selects the web runtime provider. Session scope writes `sessions.<id>.webProvider`
447
+ * (a session may choose, never change global enablement); global/workspace scope writes
448
+ * `environments.web.provider`.
449
+ *
450
+ * @param {{scope?:'global'|'workspace'|'session', environment?:string, targetId?:string|null,
451
+ * webProvider?:string|null, agentId?:string, workspaceKey?:string, expectedRevision?:number,
452
+ * agent?:object, cwd?:string, workspaceId?:string}} input
453
+ */
454
+ select({
455
+ scope = 'session',
456
+ environment,
457
+ targetId,
458
+ webProvider,
459
+ agentId,
460
+ workspaceKey,
461
+ expectedRevision,
462
+ agent = null,
463
+ cwd = null,
464
+ workspaceId = null,
465
+ } = {}) {
466
+ this.load();
467
+ const resolvedWorkspaceKey = workspaceKey ?? workspaceKeyOf({ agent, cwd, workspaceId });
468
+ const patch = {};
469
+ if (environment !== undefined) patch.environment = environment;
470
+ if (targetId !== undefined) {
471
+ if (scope === 'session') {
472
+ patch.targetId = targetId;
473
+ } else {
474
+ const selected = environment ?? (scope === 'workspace'
475
+ ? (this._config.workspaces?.[workspaceKey]?.environment ?? this._config.global.environment)
476
+ : this._config.global.environment);
477
+ patch.environments = { [selected]: { targetId } };
478
+ }
479
+ }
480
+ if (webProvider !== undefined) {
481
+ if (scope === 'session') {
482
+ patch.webProvider = webProvider;
483
+ } else {
484
+ patch.environments = {
485
+ ...(patch.environments ?? {}),
486
+ [ENVIRONMENTS.WEB]: { ...(patch.environments?.[ENVIRONMENTS.WEB] ?? {}), provider: webProvider },
487
+ };
488
+ }
489
+ }
490
+ if (Object.keys(patch).length === 0) {
491
+ throw new AdapterError(ERROR_CODES.CONFIG_INVALID, 'select requires environment, targetId or webProvider', {
492
+ details: { scope },
493
+ });
494
+ }
495
+ return this.write({ expectedRevision, scope, patch, workspaceKey: resolvedWorkspaceKey, agentId });
496
+ }
497
+
498
+ /**
499
+ * Resolve a snapshot and memoize it for the current config revision (A11).
500
+ *
501
+ * One step must see one immutable snapshot: two reads inside the same revision return the *same*
502
+ * frozen object, so admission, schema declaration, Skill discovery and execution cannot observe
503
+ * different capability lists. A config write bumps `revision`, which yields a new key and a new
504
+ * object — the change is then visible to every reader at once instead of per-reader.
505
+ */
506
+ _memoized(key, resolve) {
507
+ const cacheKey = `${this._config.revision}|${key}`;
508
+ const cached = this._snapshotCache.get(cacheKey);
509
+ if (cached !== undefined) return cached;
510
+ const snapshot = resolve();
511
+ // Keep the cache bounded: a long-lived Host may resolve many agents across many revisions.
512
+ if (this._snapshotCache.size > 256) this._snapshotCache.clear();
513
+ this._snapshotCache.set(cacheKey, snapshot);
514
+ return snapshot;
515
+ }
516
+
517
+ /** Resolve the snapshot for a DSH agent (workspace key from workspaceId or realpath(cwd)). */
518
+ resolveForAgent(agent) {
519
+ this.load();
520
+ const workspaceKey = workspaceKeyOf({ agent });
521
+ const agentId = agent?.id ?? agent?.sessionId ?? null;
522
+ const parent = agent?.session?.header?.parentSession ?? null;
523
+ return this._memoized(`agent:${String(agentId)}:${String(parent)}:${String(workspaceKey)}`, () => resolveSnapshot(
524
+ this._config,
525
+ { agent, workspaceKey },
526
+ { allowTestEnvironments: this.allowTestEnvironments },
527
+ ));
528
+ }
529
+
530
+ /** Resolve the snapshot for a generic scope ({id|sessionId, workspaceId, cwd}). */
531
+ resolveForScope(scope) {
532
+ this.load();
533
+ const sessionId = scope?.id ?? scope?.sessionId ?? null;
534
+ const cwd = scope?.cwd;
535
+ const agent = {
536
+ id: typeof sessionId === 'string' ? sessionId : null,
537
+ session: { header: { cwd: typeof cwd === 'string' ? cwd : null } },
538
+ };
539
+ const workspaceKey = workspaceKeyOf({ cwd, workspaceId: scope?.workspaceId });
540
+ return this._memoized(`scope:${String(sessionId)}:${String(cwd)}:${String(workspaceKey)}`, () => resolveSnapshot(
541
+ this._config,
542
+ { agent, workspaceKey },
543
+ { allowTestEnvironments: this.allowTestEnvironments },
544
+ ));
545
+ }
546
+
547
+ /** Subscribe to successful writes. Returns a disposer. */
548
+ subscribe(fn) {
549
+ if (typeof fn !== 'function') {
550
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'subscriber must be a function', {
551
+ details: { field: 'fn' },
552
+ });
553
+ }
554
+ this._subscribers.add(fn);
555
+ return () => {
556
+ this._subscribers.delete(fn);
557
+ };
558
+ }
559
+
560
+ /** Drop subscribers. Idempotent. */
561
+ dispose() {
562
+ this._subscribers.clear();
563
+ this._snapshotCache.clear();
564
+ this._disposed = true;
565
+ }
566
+ }