hcifootprint 0.2.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 (87) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +524 -0
  3. package/dist/atom/types.d.ts +618 -0
  4. package/dist/atom/types.d.ts.map +1 -0
  5. package/dist/atom/types.js +2 -0
  6. package/dist/atom/types.js.map +1 -0
  7. package/dist/graph/builder.d.ts +31 -0
  8. package/dist/graph/builder.d.ts.map +1 -0
  9. package/dist/graph/builder.js +165 -0
  10. package/dist/graph/builder.js.map +1 -0
  11. package/dist/graph/guards.d.ts +31 -0
  12. package/dist/graph/guards.d.ts.map +1 -0
  13. package/dist/graph/guards.js +94 -0
  14. package/dist/graph/guards.js.map +1 -0
  15. package/dist/graph/skill-deps.d.ts +20 -0
  16. package/dist/graph/skill-deps.d.ts.map +1 -0
  17. package/dist/graph/skill-deps.js +15 -0
  18. package/dist/graph/skill-deps.js.map +1 -0
  19. package/dist/index.d.ts +49 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +42 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/mcp.d.ts +16 -0
  24. package/dist/mcp.d.ts.map +1 -0
  25. package/dist/mcp.js +15 -0
  26. package/dist/mcp.js.map +1 -0
  27. package/dist/presence/presence.d.ts +50 -0
  28. package/dist/presence/presence.d.ts.map +1 -0
  29. package/dist/presence/presence.js +126 -0
  30. package/dist/presence/presence.js.map +1 -0
  31. package/dist/registry/registry.d.ts +53 -0
  32. package/dist/registry/registry.d.ts.map +1 -0
  33. package/dist/registry/registry.js +76 -0
  34. package/dist/registry/registry.js.map +1 -0
  35. package/dist/serve/mcp-server.d.ts +40 -0
  36. package/dist/serve/mcp-server.d.ts.map +1 -0
  37. package/dist/serve/mcp-server.js +72 -0
  38. package/dist/serve/mcp-server.js.map +1 -0
  39. package/dist/serve/mcp.d.ts +12 -0
  40. package/dist/serve/mcp.d.ts.map +1 -0
  41. package/dist/serve/mcp.js +62 -0
  42. package/dist/serve/mcp.js.map +1 -0
  43. package/dist/serve/modes.d.ts +66 -0
  44. package/dist/serve/modes.d.ts.map +1 -0
  45. package/dist/serve/modes.js +347 -0
  46. package/dist/serve/modes.js.map +1 -0
  47. package/dist/testing/harness.d.ts +156 -0
  48. package/dist/testing/harness.d.ts.map +1 -0
  49. package/dist/testing/harness.js +416 -0
  50. package/dist/testing/harness.js.map +1 -0
  51. package/dist/testing/index.d.ts +35 -0
  52. package/dist/testing/index.d.ts.map +1 -0
  53. package/dist/testing/index.js +32 -0
  54. package/dist/testing/index.js.map +1 -0
  55. package/dist/testing/lint.d.ts +20 -0
  56. package/dist/testing/lint.d.ts.map +1 -0
  57. package/dist/testing/lint.js +18 -0
  58. package/dist/testing/lint.js.map +1 -0
  59. package/dist/testing/model/check.d.ts +30 -0
  60. package/dist/testing/model/check.d.ts.map +1 -0
  61. package/dist/testing/model/check.js +89 -0
  62. package/dist/testing/model/check.js.map +1 -0
  63. package/dist/testing/model/lint.d.ts +54 -0
  64. package/dist/testing/model/lint.d.ts.map +1 -0
  65. package/dist/testing/model/lint.js +235 -0
  66. package/dist/testing/model/lint.js.map +1 -0
  67. package/dist/testing/model/satisfiable.d.ts +25 -0
  68. package/dist/testing/model/satisfiable.d.ts.map +1 -0
  69. package/dist/testing/model/satisfiable.js +101 -0
  70. package/dist/testing/model/satisfiable.js.map +1 -0
  71. package/dist/traverse/nav-session.d.ts +108 -0
  72. package/dist/traverse/nav-session.d.ts.map +1 -0
  73. package/dist/traverse/nav-session.js +637 -0
  74. package/dist/traverse/nav-session.js.map +1 -0
  75. package/dist/traverse/session.d.ts +239 -0
  76. package/dist/traverse/session.d.ts.map +1 -0
  77. package/dist/traverse/session.js +1305 -0
  78. package/dist/traverse/session.js.map +1 -0
  79. package/dist/tree/appmap.d.ts +8 -0
  80. package/dist/tree/appmap.d.ts.map +1 -0
  81. package/dist/tree/appmap.js +259 -0
  82. package/dist/tree/appmap.js.map +1 -0
  83. package/dist/tree/types.d.ts +151 -0
  84. package/dist/tree/types.d.ts.map +1 -0
  85. package/dist/tree/types.js +2 -0
  86. package/dist/tree/types.js.map +1 -0
  87. package/package.json +90 -0
@@ -0,0 +1,637 @@
1
+ import { detectSchema } from 'footprintjs';
2
+ import { SkillGraphValidationError, composeGuards, validateGuardShape } from '../graph/guards.js';
3
+ import { PresenceIndex } from '../presence/presence.js';
4
+ import { Session } from './session.js';
5
+ export class InteractionSession extends Session {
6
+ #map;
7
+ #presence = new PresenceIndex();
8
+ /** Deepest node evidenced by sync()/fire(). Registration NEVER writes this. */
9
+ #focusPath;
10
+ /**
11
+ * Mount-declared tool overlay: qualified id → a STACK of declarations
12
+ * (newest last = the served one). A stack, not a slot: two components may
13
+ * declare the same tool (StrictMode, list twins); releasing one must never
14
+ * delete the tool out from under the other — the newest survivor serves.
15
+ */
16
+ #dynamic = new Map();
17
+ #mergedSpec = null;
18
+ /** Foreign (off-router-page) registrations: node path → first seen (ms). */
19
+ #foreignSeen = new Map();
20
+ /** Overlay modals shown by presence alone (no signal): path → first seen (ms). */
21
+ #overlaySeen = new Map();
22
+ #warnedOnce = new Set();
23
+ #graceMs;
24
+ /** Clock for the grace timers (injectable for deterministic staleness tests). */
25
+ #now;
26
+ constructor(map, opts) {
27
+ const node = opts?.node ?? Object.keys(map.spec.pages)[0];
28
+ super(map.spec, { ...(opts ?? {}), node });
29
+ this.#map = map;
30
+ this.#focusPath = node;
31
+ this.#graceMs = opts?.dormantGraceMs ?? 3000;
32
+ this.#now = opts?.now ?? Date.now;
33
+ // Our fingerprint override reads the fields above — re-baseline now so the
34
+ // first real mutation (not construction itself) is what flushes a row.
35
+ this.resetStructureBaseline();
36
+ }
37
+ get spec() {
38
+ try {
39
+ if (this.#dynamic.size === 0)
40
+ return super.spec;
41
+ }
42
+ catch {
43
+ // super() is still constructing this instance — no overlay can exist yet.
44
+ return super.spec;
45
+ }
46
+ return (this.#mergedSpec ??= this.#buildMergedSpec());
47
+ }
48
+ #buildMergedSpec() {
49
+ const base = super.spec;
50
+ const affordances = { ...base.affordances };
51
+ for (const [qualifiedId, stack] of this.#dynamic) {
52
+ if (Object.hasOwn(base.affordances, qualifiedId))
53
+ continue; // declared-wins (warned at mount)
54
+ if (stack.length > 0)
55
+ affordances[qualifiedId] = stack[stack.length - 1].affordance;
56
+ }
57
+ return { ...base, affordances };
58
+ }
59
+ // -------------------------------------------------------------------------
60
+ // mount — one handle per rendered thing (presence + handlers + declarations)
61
+ // -------------------------------------------------------------------------
62
+ /**
63
+ * Register a component's handlers/tools ON a node when it renders. You never
64
+ * name a group — this RETURNS a ToolGroupHandle that is the identity (with a
65
+ * generated `id`). Hold it in a ref; call `handle.unregister()` on unmount.
66
+ * `handle.setEnabled(toolId, false)` greys one tool out (a disabled button).
67
+ */
68
+ registerToolGroup(path, opts) {
69
+ const node = this.#map.nodes[path];
70
+ if (!node) {
71
+ throw new Error(`hcifootprint: unknown node '${path}'. Known nodes: ${Object.keys(this.#map.nodes).join(', ')}.`);
72
+ }
73
+ if (opts?.instance !== undefined && !node.repeats) {
74
+ throw new Error(`hcifootprint: node '${path}' is not repeats: true — instance keys only apply to repeats containers.`);
75
+ }
76
+ const owner = Symbol(path);
77
+ const group = this.nextGroupId(`mount:${path}`);
78
+ const declaredHere = [];
79
+ // 1. Mount-declared leaf tools go into the overlay FIRST so handler
80
+ // resolution below (and every later lookup) sees them.
81
+ for (const [name, toolDef] of Object.entries(opts?.tools ?? {})) {
82
+ const qualifiedId = this.#declareMountTool(path, node, name, toolDef, owner, group, opts?.instance);
83
+ if (qualifiedId)
84
+ declaredHere.push(qualifiedId);
85
+ }
86
+ // 2. Bind existing app handlers (by reference) to declared tools.
87
+ for (const [name, handler] of Object.entries(opts?.handlers ?? {})) {
88
+ const qualifiedId = this.#resolveToolOnNode(path, name);
89
+ const enabled = opts?.enabled?.[name] ?? opts?.enabled?.[qualifiedId] ?? true;
90
+ this.registry.register(group, this.#registryKey(qualifiedId, opts?.instance), handler, enabled);
91
+ }
92
+ // 3. Presence + optional initial visibility signal.
93
+ const presenceHandle = this.#presence.open(path, opts?.instance);
94
+ if (opts?.visible !== undefined)
95
+ this.#presence.setVisible(path, opts.visible);
96
+ // 4. Dormancy bookkeeping: a mount outside the router-confirmed page is
97
+ // held, not offered — its clock starts now.
98
+ if (node.page !== this.node && !this.#foreignSeen.has(path)) {
99
+ this.#foreignSeen.set(path, this.#now());
100
+ }
101
+ this.noteStructureChange();
102
+ let released = false;
103
+ const unregister = () => {
104
+ if (released)
105
+ return;
106
+ released = true;
107
+ presenceHandle.release();
108
+ this.unregisterGroup(group);
109
+ let removedAny = false;
110
+ for (const qualifiedId of declaredHere) {
111
+ const stack = this.#dynamic.get(qualifiedId);
112
+ if (!stack)
113
+ continue;
114
+ const remaining = stack.filter((entry) => entry.owner !== owner);
115
+ if (remaining.length !== stack.length) {
116
+ if (remaining.length === 0)
117
+ this.#dynamic.delete(qualifiedId);
118
+ else
119
+ this.#dynamic.set(qualifiedId, remaining);
120
+ removedAny = true;
121
+ }
122
+ }
123
+ if (removedAny)
124
+ this.#mergedSpec = null;
125
+ this.noteStructureChange();
126
+ };
127
+ return {
128
+ id: group,
129
+ node: path,
130
+ ...(opts?.instance !== undefined ? { instance: opts.instance } : {}),
131
+ // Map a leaf/qualified toolId to its registry key (instance-aware).
132
+ setEnabled: (toolId, enabled) => {
133
+ const qualifiedId = this.#resolveToolOnNode(path, toolId);
134
+ this.setToolEnabled(this.#registryKey(qualifiedId, opts?.instance), enabled);
135
+ },
136
+ unregister,
137
+ };
138
+ }
139
+ /**
140
+ * Register ONE tool on a node (convenience over registerToolGroup). `def`
141
+ * either binds an existing declared tool (`{ handler }`) or declares a new
142
+ * leaf here (`{ does, handler }`). Returns a single-tool handle.
143
+ */
144
+ registerTool(path, toolId, def) {
145
+ // 'declared' covers BOTH node-scoped tools ('path.toolId') and root/
146
+ // multi-attach tools (bare 'toolId' offered on this page) — bind the handler
147
+ // to the existing tool; only declare a NEW leaf when neither exists.
148
+ const declared = this.spec.affordances[`${path}.${toolId}`] !== undefined ||
149
+ this.spec.affordances[toolId] !== undefined;
150
+ const group = declared
151
+ ? this.registerToolGroup(path, { handlers: { [toolId]: def.handler } })
152
+ : this.registerToolGroup(path, { tools: { [toolId]: def } });
153
+ return {
154
+ id: group.id,
155
+ ...(group.node !== undefined ? { node: group.node } : {}),
156
+ toolId,
157
+ setEnabled: (enabled) => group.setEnabled(toolId, enabled),
158
+ unregister: () => group.unregister(),
159
+ };
160
+ }
161
+ #declareMountTool(path, node, name, toolDef, owner, group, instance) {
162
+ if (/[.[\]#/|]/.test(name)) {
163
+ throw new SkillGraphValidationError(`mount('${path}') tool '${name}' contains a reserved character (. [ ] # / |).`);
164
+ }
165
+ const qualifiedId = `${path}.${name}`;
166
+ if (name === 'leave-skill') {
167
+ throw new SkillGraphValidationError(`tool name 'leave-skill' is reserved.`);
168
+ }
169
+ if (super.spec.affordances[qualifiedId]) {
170
+ // Declared-wins precedence: the central declaration is the audited one.
171
+ this.warn(`hcifootprint: mount('${path}') re-declares '${qualifiedId}' — the declared tool wins; ` +
172
+ `only the handler was bound.`);
173
+ if (toolDef.handler)
174
+ this.registry.register(group, this.#registryKey(qualifiedId, instance), toolDef.handler);
175
+ return null;
176
+ }
177
+ if (!toolDef.does || !toolDef.does.trim()) {
178
+ throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' needs a 'does'.`);
179
+ }
180
+ if (toolDef.when) {
181
+ if (Object.keys(toolDef.when).length === 0) {
182
+ throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' has an empty when {} — omit it.`);
183
+ }
184
+ validateGuardShape(`mount-declared tool '${qualifiedId}' when`, toolDef.when);
185
+ }
186
+ if (toolDef.goTo && !super.spec.pages[toolDef.goTo]) {
187
+ throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' goTo unknown page '${toolDef.goTo}'.`);
188
+ }
189
+ if (toolDef.input !== undefined && detectSchema(toolDef.input) === 'none') {
190
+ throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' has an unrecognized input schema.`);
191
+ }
192
+ const existing = this.#dynamic.get(qualifiedId);
193
+ if (existing && existing.length > 0) {
194
+ this.warn(`hcifootprint: '${qualifiedId}' mount-declared twice — the newest declaration serves (common ` +
195
+ `cause: a component mounted twice without releasing its handle).`);
196
+ }
197
+ const guard = composeGuards(qualifiedId, [
198
+ ...this.#guardChain(path),
199
+ ...(toolDef.when ? [toolDef.when] : []),
200
+ ]);
201
+ const affordance = Object.freeze({
202
+ id: qualifiedId,
203
+ on: [node.page],
204
+ description: toolDef.does,
205
+ binding: toolDef.binding ? structuredClone(toolDef.binding) : undefined,
206
+ guard,
207
+ effect: toolDef.writes || toolDef.goTo
208
+ ? {
209
+ ...(toolDef.writes ? { writes: [...toolDef.writes] } : {}),
210
+ ...(toolDef.goTo ? { navigatesTo: toolDef.goTo } : {}),
211
+ }
212
+ : undefined,
213
+ schema: toolDef.input,
214
+ highEffect: toolDef.confirm ?? false,
215
+ role: toolDef.role ?? (toolDef.goTo ? 'next' : 'action'),
216
+ descriptionSource: 'registration',
217
+ });
218
+ this.#dynamic.set(qualifiedId, [...(existing ?? []), { owner, affordance }]);
219
+ this.#mergedSpec = null;
220
+ if (toolDef.handler)
221
+ this.registry.register(group, this.#registryKey(qualifiedId, instance), toolDef.handler);
222
+ return qualifiedId;
223
+ }
224
+ #resolveToolOnNode(path, name) {
225
+ const qualified = `${path}.${name}`;
226
+ if (this.spec.affordances[qualified])
227
+ return qualified;
228
+ if (this.spec.affordances[name])
229
+ return name; // already-qualified id or root tool
230
+ throw new Error(`hcifootprint: mount('${path}') binds unknown tool '${name}' — declare it in appMap (or pass it in ` +
231
+ `mount({tools})). Known here: ${Object.keys(this.spec.affordances)
232
+ .filter((id) => id.startsWith(`${path}.`) || !id.includes('.'))
233
+ .join(', ')}.`);
234
+ }
235
+ #registryKey(qualifiedId, instance) {
236
+ return instance === undefined ? qualifiedId : `${qualifiedId}[${instance}]`;
237
+ }
238
+ #guardChain(path) {
239
+ const chain = [];
240
+ for (let cursor = this.#map.nodes[path]; cursor;) {
241
+ if (cursor.guard)
242
+ chain.unshift(cursor.guard);
243
+ cursor = cursor.parent ? this.#map.nodes[cursor.parent] : undefined;
244
+ }
245
+ return chain;
246
+ }
247
+ // -------------------------------------------------------------------------
248
+ // visibility wire — the one signal registration cannot infer
249
+ // -------------------------------------------------------------------------
250
+ setVisible(path, visible) {
251
+ this.#requireNode(path);
252
+ this.#presence.setVisible(path, visible);
253
+ this.noteStructureChange();
254
+ }
255
+ /** Show a node; for a tab this also hides its tab siblings (at most one shown). */
256
+ show(path) {
257
+ const node = this.#requireNode(path);
258
+ if (node.kind === 'tab' && node.parent) {
259
+ for (const siblingPath of this.#map.nodes[node.parent].children) {
260
+ const sibling = this.#map.nodes[siblingPath];
261
+ if (sibling.kind === 'tab')
262
+ this.#presence.setVisible(siblingPath, siblingPath === path);
263
+ }
264
+ }
265
+ else {
266
+ this.#presence.setVisible(path, true);
267
+ }
268
+ this.noteStructureChange();
269
+ }
270
+ #requireNode(path) {
271
+ const node = this.#map.nodes[path];
272
+ if (!node) {
273
+ throw new Error(`hcifootprint: unknown node '${path}'. Known nodes: ${Object.keys(this.#map.nodes).join(', ')}.`);
274
+ }
275
+ return node;
276
+ }
277
+ // -------------------------------------------------------------------------
278
+ // focus — evidence-based "you are here", with ancestor fallback
279
+ // -------------------------------------------------------------------------
280
+ get focus() {
281
+ let cursor = this.#focusPath;
282
+ while (true) {
283
+ const node = this.#map.nodes[cursor];
284
+ if (!node || node.page !== this.node)
285
+ return this.node; // stale/off-graph focus → page floor
286
+ if (node.kind === 'page')
287
+ return cursor;
288
+ const gate = this.#gateNode(cursor);
289
+ if (gate.served)
290
+ return cursor;
291
+ cursor = node.parent ?? this.node; // nearest active ancestor = auto-resume
292
+ }
293
+ }
294
+ // -------------------------------------------------------------------------
295
+ // the fused activation model
296
+ // -------------------------------------------------------------------------
297
+ /**
298
+ * Shown blocking modals on the current page: signal true, or present without
299
+ * a contrary signal — AND whose ancestor chain allows visibility. A modal
300
+ * kept mounted inside a hidden tab is NOT shown and must not mask anything.
301
+ */
302
+ #shownOverlays() {
303
+ const shown = [];
304
+ for (const node of Object.values(this.#map.nodes)) {
305
+ if (!node.overlay || node.page !== this.node)
306
+ continue;
307
+ const visibility = this.#presence.visibility(node.path);
308
+ const present = this.#presence.isPresent(node.path);
309
+ const selfShown = visibility === true || (visibility === undefined && present);
310
+ const ancestorsAllow = node.parent === null || this.#gateChain(node.parent).served;
311
+ if (selfShown && ancestorsAllow) {
312
+ shown.push(node.path);
313
+ if (visibility === undefined)
314
+ this.#overlayGraceCheck(node.path);
315
+ else
316
+ this.#overlaySeen.delete(node.path);
317
+ }
318
+ else {
319
+ this.#overlaySeen.delete(node.path);
320
+ }
321
+ }
322
+ return shown;
323
+ }
324
+ /** Overlay self-defense: a modal masking its page on presence alone, past grace, names the missing wire. */
325
+ #overlayGraceCheck(path) {
326
+ const firstSeen = this.#overlaySeen.get(path);
327
+ if (firstSeen === undefined) {
328
+ this.#overlaySeen.set(path, this.#now());
329
+ return;
330
+ }
331
+ const warnKey = `overlay:${path}`;
332
+ if (this.#now() - firstSeen >= this.#graceMs && !this.#warnedOnce.has(warnKey)) {
333
+ this.#warnedOnce.add(warnKey);
334
+ this.warn(`hcifootprint: modal '${path}' has been masking its page on mount-presence alone for over ` +
335
+ `${this.#graceMs}ms with no visibility signal. If it is actually closed (kept mounted for ` +
336
+ `animation), wire session.setVisible('${path}', false) — mounts cannot see CSS.`);
337
+ }
338
+ }
339
+ #gateNode(path) {
340
+ // Overlay masking first. With shown blocking modals, a node is served only
341
+ // if it lives inside AT LEAST ONE of them — "outside ANY shown modal"
342
+ // would make two simultaneously-shown modals mask each other (total page
343
+ // deadlock, both modals' own close buttons included).
344
+ const overlays = this.#shownOverlays();
345
+ if (overlays.length > 0) {
346
+ const inside = overlays.some((overlay) => path === overlay || path.startsWith(`${overlay}.`));
347
+ if (!inside)
348
+ return { served: false, reason: 'BLOCKED_BY_OVERLAY', overlay: overlays[0] };
349
+ }
350
+ return this.#gateChain(path);
351
+ }
352
+ /** The chain walk (visibility, modal-shown, tab prior) WITHOUT overlay masking. */
353
+ #gateChain(path) {
354
+ // Walk root → node; containers can only reject or degrade certainty.
355
+ const chain = [];
356
+ for (let cursor = this.#map.nodes[path]; cursor;) {
357
+ chain.unshift(cursor);
358
+ cursor = cursor.parent ? this.#map.nodes[cursor.parent] : undefined;
359
+ }
360
+ let presenceUnknown = false;
361
+ let activation = 'assumed';
362
+ for (const node of chain) {
363
+ const visibility = this.#presence.visibility(node.path);
364
+ if (visibility === false)
365
+ return { served: false, reason: 'NODE_NOT_VISIBLE', node: node.path };
366
+ const present = this.#presence.isPresent(node.path);
367
+ if (node.kind === 'page') {
368
+ activation = 'synced'; // router-confirmed
369
+ continue;
370
+ }
371
+ if (node.kind === 'modal') {
372
+ // NEVER assumed: closed until registered or shown.
373
+ if (visibility !== true && !present) {
374
+ return { served: false, reason: 'NODE_NOT_VISIBLE', node: node.path };
375
+ }
376
+ activation = visibility === true ? 'shown' : 'registered';
377
+ continue;
378
+ }
379
+ if (node.kind === 'tab') {
380
+ const gate = this.#gateTab(node, visibility, present);
381
+ if (!gate.served)
382
+ return gate;
383
+ activation = gate.activation;
384
+ presenceUnknown ||= gate.presenceUnknown ?? false;
385
+ continue;
386
+ }
387
+ // areas / plain containers: mounted sharpens, signals decide, absence stays assumed
388
+ activation = visibility === true ? 'shown' : present ? 'registered' : 'assumed';
389
+ }
390
+ return { served: true, activation, ...(presenceUnknown ? { presenceUnknown: true } : {}) };
391
+ }
392
+ #gateTab(tab, visibility, present) {
393
+ if (visibility === true)
394
+ return { served: true, activation: 'shown' };
395
+ const siblings = tab.parent
396
+ ? this.#map.nodes[tab.parent].children
397
+ .map((childPath) => this.#map.nodes[childPath])
398
+ .filter((child) => child.kind === 'tab')
399
+ : [tab];
400
+ // The exclusivity PRIOR: a sibling explicitly shown means this one is not.
401
+ const siblingShown = siblings.some((sibling) => sibling.path !== tab.path && this.#presence.visibility(sibling.path) === true);
402
+ if (siblingShown)
403
+ return { served: false, reason: 'NODE_NOT_VISIBLE', node: tab.path };
404
+ // Explicitly-hidden siblings are not candidates: with tab A mounted-but-
405
+ // signaled-hidden and tab B unmounted, B is the plausibly-shown one — a
406
+ // hidden mount must not make its siblings unreachable.
407
+ const mountedSiblings = siblings.filter((sibling) => this.#presence.isPresent(sibling.path) && this.#presence.visibility(sibling.path) !== false);
408
+ if (mountedSiblings.length === 0) {
409
+ // L0: nothing registers → the flagged union (assumed), never a guessed winner.
410
+ return { served: true, activation: 'assumed', presenceUnknown: siblings.length > 1 };
411
+ }
412
+ if (!present) {
413
+ // Mounts exist and none of them is this tab: it is really not there (yet).
414
+ return { served: false, reason: 'NODE_NOT_VISIBLE', node: tab.path };
415
+ }
416
+ if (mountedSiblings.length > 1) {
417
+ // Keep-mounted panels with no wire: serve the union, flagged, and say the one-line upgrade once.
418
+ const warnKey = `tabs:${tab.parent ?? tab.path}`;
419
+ if (!this.#warnedOnce.has(warnKey)) {
420
+ this.#warnedOnce.add(warnKey);
421
+ this.warn(`hcifootprint: ${mountedSiblings.length} tab siblings under '${tab.parent}' are mounted at once ` +
422
+ `and no visibility signal exists — serving all of them flagged presence:'unknown'. One line fixes ` +
423
+ `it: session.show('<the visible tab>') on tab change (or pass visible: at mount).`);
424
+ }
425
+ return { served: true, activation: 'registered', presenceUnknown: true };
426
+ }
427
+ return { served: true, activation: 'registered' };
428
+ }
429
+ // -------------------------------------------------------------------------
430
+ // available / fire / sync / brief — the Session surface, tree-gated
431
+ // -------------------------------------------------------------------------
432
+ available() {
433
+ this.#driftCheck();
434
+ const base = super.available();
435
+ const edges = [];
436
+ for (const edge of base.edges) {
437
+ const path = this.#pathOnCurrentPage(edge.affordanceId);
438
+ if (!path) {
439
+ edges.push(edge); // defensive: a projection tool with no tree node keeps v1 semantics
440
+ continue;
441
+ }
442
+ const gate = this.#gateNode(path);
443
+ if (!gate.served)
444
+ continue; // fire() on it returns the typed reason — available() just omits
445
+ const node = this.#map.nodes[path];
446
+ const stamped = {
447
+ ...edge,
448
+ node: path,
449
+ activation: gate.activation,
450
+ ...(gate.presenceUnknown ? { presence: 'unknown' } : {}),
451
+ };
452
+ if (node.repeats) {
453
+ const existence = this.#instanceExistence(node);
454
+ stamped.instances = existence.keys.slice(0, 50); // render cap only — fireability is uncapped
455
+ stamped.enumeration = existence.enumeration;
456
+ }
457
+ edges.push(stamped);
458
+ }
459
+ return { ...base, edges };
460
+ }
461
+ fire(affordanceId, opts) {
462
+ const affordance = this.spec.affordances[affordanceId];
463
+ if (affordance) {
464
+ const path = this.#pathOnCurrentPage(affordanceId);
465
+ if (path) {
466
+ const gate = this.#gateNode(path);
467
+ if (!gate.served) {
468
+ this.recordRejection(affordanceId, gate.reason, opts.source);
469
+ return gate.reason === 'BLOCKED_BY_OVERLAY'
470
+ ? { ok: false, reason: 'BLOCKED_BY_OVERLAY', overlay: gate.overlay }
471
+ : { ok: false, reason: 'NODE_NOT_VISIBLE', node: gate.node };
472
+ }
473
+ const node = this.#map.nodes[path];
474
+ if (node.repeats) {
475
+ const existence = this.#instanceExistence(node);
476
+ if (opts.instance === undefined) {
477
+ this.recordRejection(affordanceId, 'INSTANCE_REQUIRED', opts.source);
478
+ return { ok: false, reason: 'INSTANCE_REQUIRED', instances: existence.keys.slice(0, 50) };
479
+ }
480
+ // Membership against the FULL set — the render cap must never make
481
+ // instance #51 unfireable.
482
+ if (!existence.keys.includes(opts.instance)) {
483
+ this.recordRejection(affordanceId, 'INSTANCE_UNKNOWN', opts.source);
484
+ return { ok: false, reason: 'INSTANCE_UNKNOWN', instances: existence.keys.slice(0, 50) };
485
+ }
486
+ }
487
+ if (gate.activation === 'assumed' &&
488
+ opts.invoke !== false &&
489
+ this.#presence.hasAny() &&
490
+ this.handlerFor(affordanceId, opts) === undefined) {
491
+ // The session runs on mounts, this node's have not arrived, and the
492
+ // caller wanted execution: retriable, and never a fake GUARD_FAILED.
493
+ this.recordRejection(affordanceId, 'STILL_MOUNTING', opts.source);
494
+ return { ok: false, reason: 'STILL_MOUNTING', node: path };
495
+ }
496
+ const result = super.fire(affordanceId, opts);
497
+ if (result.ok)
498
+ this.#focusPath = path; // fire evidence moves focus
499
+ return result;
500
+ }
501
+ }
502
+ return super.fire(affordanceId, opts);
503
+ }
504
+ handlerFor(affordanceId, opts) {
505
+ if (opts.instance !== undefined) {
506
+ return (this.registry.handlerFor(this.#registryKey(affordanceId, opts.instance)) ??
507
+ this.registry.handlerFor(affordanceId));
508
+ }
509
+ return super.handlerFor(affordanceId, opts);
510
+ }
511
+ /**
512
+ * Instance-aware disabled gate: a per-row button registered disabled under
513
+ * 'id[instance]' must block, matching how handlerFor resolves it. Falls back
514
+ * to the base (non-instance) registration.
515
+ */
516
+ isToolDisabled(affordanceId, opts) {
517
+ if (opts.instance !== undefined) {
518
+ const keyed = this.registry.isEnabled(this.#registryKey(affordanceId, opts.instance));
519
+ if (keyed !== undefined)
520
+ return keyed === false;
521
+ }
522
+ return this.registry.isEnabled(affordanceId) === false;
523
+ }
524
+ sync(observedNode, opts) {
525
+ const result = super.sync(observedNode, opts);
526
+ if (result.changed) {
527
+ this.#focusPath = this.node; // router evidence resets focus to the page
528
+ // Dormant registrations under the now-confirmed page become native…
529
+ for (const path of [...this.#foreignSeen.keys()]) {
530
+ if (this.#map.nodes[path]?.page === this.node)
531
+ this.#foreignSeen.delete(path);
532
+ }
533
+ // …and still-mounted nodes elsewhere start (or keep) their drift clocks.
534
+ for (const path of this.#presence.presentNodes()) {
535
+ const node = this.#map.nodes[path];
536
+ if (node && node.page !== this.node && !this.#foreignSeen.has(path)) {
537
+ this.#foreignSeen.set(path, this.#now());
538
+ }
539
+ }
540
+ }
541
+ return result;
542
+ }
543
+ contextBrief(opts) {
544
+ this.#driftCheck();
545
+ const brief = super.contextBrief(opts);
546
+ const lines = [];
547
+ if (this.focus !== this.node)
548
+ lines.push(`Focus: ${this.focus}.`);
549
+ const frontier = this.#presence
550
+ .presentNodes()
551
+ .filter((path) => this.#map.nodes[path]?.page === this.node)
552
+ .sort();
553
+ if (frontier.length > 0)
554
+ lines.push(`Mounted here: ${frontier.join(', ')}.`);
555
+ return lines.length > 0 ? { ...brief, text: `${brief.text}\n${lines.join('\n')}` } : brief;
556
+ }
557
+ // -------------------------------------------------------------------------
558
+ // internals
559
+ // -------------------------------------------------------------------------
560
+ /** The tool's node on the CURRENT page, or null (root tools resolve to the page node). */
561
+ #pathOnCurrentPage(affordanceId) {
562
+ const declared = this.#map.toolNodes[affordanceId];
563
+ if (declared) {
564
+ return declared.find((path) => this.#map.nodes[path]?.page === this.node) ?? null;
565
+ }
566
+ if (this.#dynamic.has(affordanceId)) {
567
+ const path = affordanceId.split('.').slice(0, -1).join('.');
568
+ return this.#map.nodes[path]?.page === this.node ? path : null;
569
+ }
570
+ return null;
571
+ }
572
+ /**
573
+ * The FULL existence set (sanitized, unbounded). Fire-time membership checks
574
+ * run against ALL of it — instance #51 is real. Serving layers cap what they
575
+ * RENDER (50 keys on an edge), never what is fireable.
576
+ */
577
+ #instanceExistence(node) {
578
+ if (node.instances) {
579
+ try {
580
+ const keys = node.instances(this.state());
581
+ if (Array.isArray(keys)) {
582
+ return { keys: keys.map(sanitizeKey), enumeration: 'selector' };
583
+ }
584
+ this.warn(`hcifootprint: instances source for '${node.path}' returned a non-array — using the mounted window.`);
585
+ }
586
+ catch (error) {
587
+ this.warn(`hcifootprint: instances source for '${node.path}' threw: ${String(error)} — using the mounted window.`);
588
+ }
589
+ }
590
+ return {
591
+ keys: this.#presence.instancesOf(node.path).map(sanitizeKey),
592
+ enumeration: 'mounted-window',
593
+ };
594
+ }
595
+ /** Foreign registrations past grace = sensor drift: one dev warning + one gap row, per node. */
596
+ #driftCheck() {
597
+ const now = this.#now();
598
+ for (const [path, firstSeen] of [...this.#foreignSeen]) {
599
+ if (!this.#presence.isPresent(path) || this.#map.nodes[path]?.page === this.node) {
600
+ this.#foreignSeen.delete(path);
601
+ continue;
602
+ }
603
+ const warnKey = `foreign:${path}`;
604
+ if (now - firstSeen >= this.#graceMs && !this.#warnedOnce.has(warnKey)) {
605
+ this.#warnedOnce.add(warnKey);
606
+ this.warn(`hcifootprint: '${path}' has been mounted for over ${this.#graceMs}ms while the router says ` +
607
+ `'${this.node}' — its tools are dormant. Either sync() is missing a page change or a component ` +
608
+ `outlived its page (exit animation / portal). Recorded as a sensor-drift gap row.`);
609
+ this.reportGap({
610
+ request: `dormant registration: node '${path}' mounted while router-confirmed page is '${this.node}'`,
611
+ reason: 'sensor-drift',
612
+ principal: 'system',
613
+ });
614
+ }
615
+ }
616
+ }
617
+ structureFingerprint() {
618
+ // Instance-keyed handler registrations ('id[key]') are EXCLUDED: a
619
+ // scrolling virtualized list must never bump the cursor or write rows.
620
+ // Enabled state IS included — a setEnabled flip changes the served surface.
621
+ const handlers = this.registry
622
+ .registrations()
623
+ // Enabled instance-keyed registrations ('id[key]') are excluded (scroll
624
+ // churn is not world motion) — but a DISABLED instance tool IS kept, so a
625
+ // per-row setEnabled(false) flip changes the fingerprint and flushes.
626
+ .filter((registration) => !registration.affordanceId.includes('[') || !registration.enabled)
627
+ .map((registration) => registration.affordanceId + (registration.enabled ? '' : ':off'))
628
+ .sort()
629
+ .join('|');
630
+ return `${handlers}::${this.#presence.fingerprint()}::dyn=${[...this.#dynamic.keys()].sort().join('|')}`;
631
+ }
632
+ }
633
+ function sanitizeKey(key) {
634
+ // eslint-disable-next-line no-control-regex
635
+ return String(key).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 120);
636
+ }
637
+ //# sourceMappingURL=nav-session.js.map