@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.2

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 (129) hide show
  1. package/AGENTS.md +104 -35
  2. package/README.md +390 -494
  3. package/docs/architecture.md +423 -20
  4. package/package.json +11 -8
  5. package/packages/extension/package.json +11 -4
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
  8. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  14. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  15. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  16. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  17. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  18. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  19. package/packages/extension/src/ask-user-tool.ts +170 -61
  20. package/packages/extension/src/bridge.ts +199 -19
  21. package/packages/extension/src/multiselect-decode.ts +40 -0
  22. package/packages/extension/src/multiselect-list.ts +146 -0
  23. package/packages/extension/src/multiselect-polyfill.ts +73 -0
  24. package/packages/extension/src/server-launcher.ts +15 -3
  25. package/packages/extension/src/ui-modules.ts +272 -0
  26. package/packages/server/package.json +11 -5
  27. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  28. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  29. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  30. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  31. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  32. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  33. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  34. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  35. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  36. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  37. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  38. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  39. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  40. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  41. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  43. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  44. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  45. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  46. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  47. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  49. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  50. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  51. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  52. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  53. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  54. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  55. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  56. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  57. package/packages/server/src/browse.ts +118 -13
  58. package/packages/server/src/browser-gateway.ts +19 -0
  59. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  60. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  61. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  63. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  64. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  65. package/packages/server/src/cli.ts +61 -15
  66. package/packages/server/src/directory-service.ts +156 -15
  67. package/packages/server/src/event-wiring.ts +111 -10
  68. package/packages/server/src/installed-package-enricher.ts +143 -0
  69. package/packages/server/src/package-manager-wrapper.ts +305 -8
  70. package/packages/server/src/package-source-helpers.ts +104 -0
  71. package/packages/server/src/pending-attach-registry.ts +112 -0
  72. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  73. package/packages/server/src/pi-core-checker.ts +9 -14
  74. package/packages/server/src/pi-gateway.ts +14 -0
  75. package/packages/server/src/pi-version-skew.ts +12 -1
  76. package/packages/server/src/proposal-attach-naming.ts +47 -0
  77. package/packages/server/src/restart-helper.ts +13 -2
  78. package/packages/server/src/routes/file-routes.ts +29 -3
  79. package/packages/server/src/routes/package-routes.ts +72 -3
  80. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  81. package/packages/server/src/routes/system-routes.ts +2 -0
  82. package/packages/server/src/server.ts +339 -10
  83. package/packages/server/src/session-api.ts +30 -5
  84. package/packages/server/src/session-order-manager.ts +22 -0
  85. package/packages/server/src/session-scanner.ts +10 -1
  86. package/packages/shared/package.json +9 -2
  87. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  88. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  89. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  90. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  91. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  93. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  94. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  95. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  96. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  97. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  98. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  99. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  100. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  101. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  102. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  103. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  104. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  105. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  106. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  107. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  108. package/packages/shared/src/browser-protocol.ts +110 -4
  109. package/packages/shared/src/config.ts +45 -0
  110. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  111. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  112. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  113. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  114. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  115. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  116. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  117. package/packages/shared/src/openspec-poller.ts +117 -3
  118. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  119. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  120. package/packages/shared/src/platform/index.ts +1 -0
  121. package/packages/shared/src/platform/node-spawn.ts +154 -0
  122. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  123. package/packages/shared/src/protocol.ts +79 -2
  124. package/packages/shared/src/recommended-extensions.ts +7 -1
  125. package/packages/shared/src/rest-api.ts +68 -3
  126. package/packages/shared/src/state-replay.ts +20 -1
  127. package/packages/shared/src/tool-registry/definitions.ts +92 -0
  128. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  129. package/packages/shared/src/types.ts +160 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Extension UI System — Phase 1 (management-modal slot).
3
+ *
4
+ * Implements the bridge side of the discovery probe and the
5
+ * `ui_management` round-trip described in
6
+ * `openspec/changes/add-extension-ui-modal/design.md` §4.
7
+ *
8
+ * Lifecycle (from `bridge.ts`):
9
+ * - `subscribeUiInvalidate(ctx)` — once per session, attaches a single
10
+ * `ui:invalidate` listener that triggers
11
+ * a full re-probe.
12
+ * - `refreshUiModules(ctx)` — fires the `ui:list-modules` probe and
13
+ * forwards the resulting array as a
14
+ * `ui_modules_list` protocol message.
15
+ * Called on `session_start` and after
16
+ * every reconnect.
17
+ * - `handleUiManagement(ctx,msg)` — receives `ui_management` from the
18
+ * server, re-emits to extensions on
19
+ * `pi.events`, and forwards any
20
+ * synchronous `data.items` back as a
21
+ * `ui_data_list` protocol message.
22
+ *
23
+ * The probe is **synchronous**: extensions push descriptors into
24
+ * `probe.modules` while `pi.events.emit` runs. We never poll, never cache
25
+ * across probes, and never register modules on the extension's behalf.
26
+ *
27
+ * No-dashboard fallback: when `connection` is not yet open, `ConnectionManager`
28
+ * buffers the outgoing messages and flushes on connect. No extra guards are
29
+ * needed here; the bridge's existing `sessionReady` gate is the upstream
30
+ * trigger guard for `session_start`-driven probes.
31
+ */
32
+ import type { ExtensionUiModule, DecoratorDescriptor } from "@blackbelt-technology/pi-dashboard-shared/types.js";
33
+
34
+ // ── Phase 2 (add-extension-ui-decorations) ────────────────────────────────
35
+
36
+ /** Decorator kinds partitioned out of `probe.modules` for Phase-2 forwarding. */
37
+ const DECORATOR_KINDS = new Set([
38
+ "footer-segment",
39
+ "agent-metric",
40
+ "breadcrumb",
41
+ "gate",
42
+ "toast",
43
+ ]);
44
+
45
+ /** Namespace must be non-empty and match `/^[a-z0-9-]+$/`. */
46
+ const NAMESPACE_RE = /^[a-z0-9-]+$/;
47
+
48
+ /** Default per-session rate cap for `ui:invalidate` re-probes. */
49
+ export const INVALIDATE_RATE_CAP_PER_SEC = 20;
50
+ /** Minimum interval between probes implied by the rate cap. */
51
+ const MIN_PROBE_INTERVAL_MS = Math.ceil(1000 / INVALIDATE_RATE_CAP_PER_SEC); // = 50ms
52
+
53
+ /**
54
+ * Subset of the bridge's mutable context that this module touches. Mirrors the
55
+ * `BridgeContext` shape but kept structurally typed to avoid the bridge
56
+ * importing extension-internal types.
57
+ */
58
+ export interface UiModulesBridgeCtx {
59
+ pi: { events?: { on(event: string, fn: (...args: any[]) => any): void; emit(event: string, ...args: any[]): any } };
60
+ connection: { send(msg: unknown): void };
61
+ /** Read at probe / forward time so the most recent session id is used. */
62
+ getSessionId(): string;
63
+ }
64
+
65
+ /**
66
+ * Server → extension `ui_management` message. Keep the shape loose so this
67
+ * module compiles without depending on the protocol union (which lives in the
68
+ * shared package and would create a cycle for unit tests).
69
+ */
70
+ export interface UiManagementInbound {
71
+ type: "ui_management";
72
+ sessionId: string;
73
+ action: string;
74
+ event: string;
75
+ params?: Record<string, unknown>;
76
+ }
77
+
78
+ /**
79
+ * Run the discovery probe for `ctx`. Synchronous — collects whatever
80
+ * extensions push into `probe.modules` during the `pi.events.emit` call and
81
+ * forwards the populated list to the server via `connection.send`.
82
+ *
83
+ * Last-write-wins on duplicate `id` within a single probe; collisions log a
84
+ * single `console.warn` per duplicate id (Decision §2 / spec scenario
85
+ * "Last-write-wins on duplicate id").
86
+ */
87
+ export function refreshUiModules(ctx: UiModulesBridgeCtx): void {
88
+ const events = ctx.pi.events;
89
+ if (!events || typeof events.emit !== "function") return;
90
+
91
+ // Probe accepts the union of Phase-1 modules + Phase-2 decorators (which may
92
+ // additionally carry a top-level `removed: true` flag from the extension).
93
+ const probe = { modules: [] as Array<ExtensionUiModule | (DecoratorDescriptor & { removed?: boolean })> };
94
+ try {
95
+ events.emit("ui:list-modules", probe);
96
+ } catch (err) {
97
+ console.error("[dashboard][ui-modules] probe emit failed:", err);
98
+ return;
99
+ }
100
+
101
+ // Phase-1 partition: management-modal modules → ui_modules_list.
102
+ // Last-write-wins on duplicate id; warn once per collision.
103
+ const byId = new Map<string, ExtensionUiModule>();
104
+ const moduleWarned = new Set<string>();
105
+
106
+ // Phase-2 partition: decorators → one ext_ui_decorator per descriptor.
107
+ // Last-write-wins on `(kind, namespace, id)` collision within one probe;
108
+ // one warning per colliding key.
109
+ const decoratorByKey = new Map<string, DecoratorDescriptor & { removed?: boolean }>();
110
+ const decoratorWarned = new Set<string>();
111
+
112
+ for (const entry of probe.modules) {
113
+ if (!entry || typeof (entry as any).kind !== "string") continue;
114
+
115
+ if ((entry as any).kind === "management-modal") {
116
+ const mod = entry as ExtensionUiModule;
117
+ if (typeof mod.id !== "string" || mod.id.length === 0) continue;
118
+ if (byId.has(mod.id) && !moduleWarned.has(mod.id)) {
119
+ moduleWarned.add(mod.id);
120
+ console.warn(`[dashboard][ui-modules] duplicate module id "${mod.id}" — last-write-wins`);
121
+ }
122
+ byId.set(mod.id, mod);
123
+ continue;
124
+ }
125
+
126
+ if (DECORATOR_KINDS.has((entry as any).kind)) {
127
+ const dec = entry as DecoratorDescriptor & { removed?: boolean };
128
+ if (typeof dec.namespace !== "string" || !NAMESPACE_RE.test(dec.namespace)) {
129
+ console.warn(
130
+ `[dashboard][ui-modules] dropping ${dec.kind} descriptor: invalid namespace ${JSON.stringify(dec.namespace)} (must match /^[a-z0-9-]+$/)`,
131
+ );
132
+ continue;
133
+ }
134
+ if (typeof dec.id !== "string" || dec.id.length === 0) {
135
+ console.warn(`[dashboard][ui-modules] dropping ${dec.kind} descriptor: missing/empty id`);
136
+ continue;
137
+ }
138
+ const key = `${dec.kind}:${dec.namespace}:${dec.id}`;
139
+ if (decoratorByKey.has(key) && !decoratorWarned.has(key)) {
140
+ decoratorWarned.add(key);
141
+ console.warn(`[dashboard][ui-modules] duplicate decorator key "${key}" — last-write-wins`);
142
+ }
143
+ decoratorByKey.set(key, dec);
144
+ continue;
145
+ }
146
+
147
+ // Unknown kind — ignore silently (forward-compat for future kinds).
148
+ }
149
+
150
+ const modules = Array.from(byId.values());
151
+ const sessionId = ctx.getSessionId();
152
+ ctx.connection.send({
153
+ type: "ui_modules_list",
154
+ sessionId,
155
+ modules,
156
+ });
157
+
158
+ for (const dec of decoratorByKey.values()) {
159
+ const { removed, ...descriptor } = dec;
160
+ const msg: Record<string, unknown> = {
161
+ type: "ext_ui_decorator",
162
+ sessionId,
163
+ descriptor,
164
+ };
165
+ if (removed === true) msg.removed = true;
166
+ ctx.connection.send(msg);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Attach the `ui:invalidate` listener for this session. Idempotent — the
172
+ * caller is responsible for invoking exactly once per session lifetime
173
+ * (typically inside the `session_start` handler).
174
+ *
175
+ * The optional `{ id }` payload is logged for telemetry only — Phase 1 always
176
+ * re-probes the full module set.
177
+ */
178
+ export function subscribeUiInvalidate(ctx: UiModulesBridgeCtx): void {
179
+ const events = ctx.pi.events;
180
+ if (!events || typeof events.on !== "function") return;
181
+
182
+ // Per-session rate cap on `ui:invalidate` re-probes. Throttled to one probe
183
+ // per `MIN_PROBE_INTERVAL_MS` (= 50ms, i.e. 20/sec): leading edge fires
184
+ // immediately; subsequent invalidations within the window coalesce into a
185
+ // single trailing-edge probe. One warning per offending burst, latched
186
+ // until a full quiet window passes.
187
+ let lastProbeAt = -Infinity;
188
+ let trailingScheduled = false;
189
+ let burstWarned = false;
190
+
191
+ const fireProbe = () => {
192
+ lastProbeAt = Date.now();
193
+ refreshUiModules(ctx);
194
+ };
195
+
196
+ events.on("ui:invalidate", () => {
197
+ const now = Date.now();
198
+ const sinceLast = now - lastProbeAt;
199
+ if (sinceLast >= MIN_PROBE_INTERVAL_MS) {
200
+ // Leading edge — fire immediately. If the previous burst settled
201
+ // (i.e. >= one quiet window has elapsed), reset the warning latch.
202
+ if (sinceLast >= MIN_PROBE_INTERVAL_MS * 2) burstWarned = false;
203
+ fireProbe();
204
+ return;
205
+ }
206
+ if (!burstWarned) {
207
+ burstWarned = true;
208
+ console.warn(
209
+ `[dashboard][ui-modules] ui:invalidate rate cap exceeded ` +
210
+ `(>${INVALIDATE_RATE_CAP_PER_SEC}/sec); coalescing further invalidations to a trailing-edge probe`,
211
+ );
212
+ }
213
+ if (!trailingScheduled) {
214
+ trailingScheduled = true;
215
+ const delay = Math.max(0, MIN_PROBE_INTERVAL_MS - sinceLast);
216
+ setTimeout(() => {
217
+ trailingScheduled = false;
218
+ fireProbe();
219
+ }, delay);
220
+ }
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Handle a server-originated `ui_management` message. Re-emits on
226
+ * `pi.events` with `_reply` injected so listeners can either:
227
+ *
228
+ * 1. Push synchronous row data into `data.items` (used by `action: "list"`).
229
+ * 2. Call `data._reply(items)` to forward asynchronously.
230
+ *
231
+ * Either path produces a `ui_data_list { sessionId, event, items }` message
232
+ * back to the server.
233
+ *
234
+ * Fire-and-forget actions (e.g. `delete-row`) typically don't reply; the
235
+ * extension follows up with `pi.events.emit("ui:invalidate", { id })` to
236
+ * trigger a fresh probe.
237
+ */
238
+ export function handleUiManagement(ctx: UiModulesBridgeCtx, msg: UiManagementInbound): void {
239
+ const events = ctx.pi.events;
240
+ if (!events || typeof events.emit !== "function") return;
241
+
242
+ let replied = false;
243
+ const reply = (items: unknown[]) => {
244
+ if (replied) return;
245
+ replied = true;
246
+ if (!Array.isArray(items)) return;
247
+ ctx.connection.send({
248
+ type: "ui_data_list",
249
+ sessionId: ctx.getSessionId(),
250
+ event: msg.event,
251
+ items,
252
+ });
253
+ };
254
+
255
+ const data: { items?: unknown[]; action: string; _reply: (items: unknown[]) => void } & Record<string, unknown> = {
256
+ ...(msg.params ?? {}),
257
+ action: msg.action,
258
+ _reply: reply,
259
+ };
260
+
261
+ try {
262
+ events.emit(msg.event, data);
263
+ } catch (err) {
264
+ console.error(`[dashboard][ui-modules] handler for "${msg.event}" threw:`, err);
265
+ return;
266
+ }
267
+
268
+ // Synchronous fast path: extension populated data.items directly.
269
+ if (!replied && Array.isArray(data.items)) {
270
+ reply(data.items);
271
+ }
272
+ }
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/BlackBeltTechnology/pi-agent-dashboard",
9
+ "directory": "packages/server"
10
+ },
6
11
  "publishConfig": {
7
12
  "access": "public"
8
13
  },
@@ -10,8 +15,8 @@
10
15
  "node": ">=22.18.0"
11
16
  },
12
17
  "piCompatibility": {
13
- "minimum": "0.6.7",
14
- "recommended": "0.6.7",
18
+ "minimum": "0.70.0",
19
+ "recommended": "0.70.0",
15
20
  "maximum": null
16
21
  },
17
22
  "main": "src/cli.ts",
@@ -26,8 +31,9 @@
26
31
  "postinstall": "node scripts/fix-pty-permissions.cjs"
27
32
  },
28
33
  "dependencies": {
29
- "@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
30
- "@blackbelt-technology/pi-dashboard-shared": "^0.4.0",
34
+ "@blackbelt-technology/dashboard-plugin-runtime": "^0.4.2",
35
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.2",
36
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.2",
31
37
  "@fastify/compress": "^8.3.1",
32
38
  "@fastify/cookie": "^11.0.2",
33
39
  "@fastify/cors": "^11.0.0",
@@ -170,18 +170,71 @@ describe("Auto-attach from openspec activity", () => {
170
170
  expect(session?.name).toBe("cool-feature");
171
171
  });
172
172
 
173
- it("does not auto-attach when proposal is already attached", async () => {
174
- // First, attach
175
- sendToolEvent(ws, "s1", { phase: "apply" });
176
- sendToolEvent(ws, "s1", { changeName: "add-auth" });
173
+ // Auto-detect re-attach (witness rule). See change: fix-mobile-attach-proposal-display
174
+ // (design.md §"Auto-detect parallel path"). The previous behavior
175
+ // (`!updatedSession.attachedProposal` guard) had the one-shot pathology this
176
+ // change fixes: an auto-tracked attachment could not be replaced even when a
177
+ // different changeName was detected.
178
+
179
+ it("§2A.2[1] fresh session — auto-attaches and auto-names", async () => {
180
+ sendToolEvent(ws, "s1", { changeName: "bar" });
177
181
  await new Promise((r) => setTimeout(r, 80));
182
+ const s = server.sessionManager.get("s1");
183
+ expect(s?.attachedProposal).toBe("bar");
184
+ expect(s?.name).toBe("bar");
185
+ });
178
186
 
179
- // Try to attach a different change
180
- sendToolEvent(ws, "s1", { changeName: "other-change" });
187
+ it("§2A.2[2] auto-tracked session re-attaches when a different changeName is detected", async () => {
188
+ sendToolEvent(ws, "s1", { changeName: "foo" });
181
189
  await new Promise((r) => setTimeout(r, 80));
190
+ let s = server.sessionManager.get("s1");
191
+ expect(s?.attachedProposal).toBe("foo");
192
+ expect(s?.name).toBe("foo");
182
193
 
183
- const session = server.sessionManager.get("s1");
184
- expect(session?.attachedProposal).toBe("add-auth");
194
+ // Different changeName via active tool — witness arm should re-attach.
195
+ sendToolEvent(ws, "s1", { changeName: "bar" });
196
+ await new Promise((r) => setTimeout(r, 80));
197
+ s = server.sessionManager.get("s1");
198
+ expect(s?.attachedProposal).toBe("bar");
199
+ expect(s?.name).toBe("bar");
200
+ expect(s?.openspecChange).toBe("bar");
201
+ });
202
+
203
+ it("§2A.2[3] custom-named session — openspecChange tracks reality, attached/name preserved", async () => {
204
+ // Set custom name + auto-attach foo via earlier activity
205
+ server.sessionManager.update("s1", { name: "my custom" } as any);
206
+ sendToolEvent(ws, "s1", { changeName: "foo" });
207
+ await new Promise((r) => setTimeout(r, 80));
208
+ let s = server.sessionManager.get("s1");
209
+ // attach happens (attachmentWasAutoTracked: attached=null counts as auto)
210
+ // BUT name stays "my custom" because attachRenameTarget returns undefined
211
+ // when name is custom and attached is null.
212
+ expect(s?.attachedProposal).toBe("foo");
213
+ expect(s?.name).toBe("my custom");
214
+
215
+ // Different changeName detected. attachmentWasAutoTracked = false because
216
+ // name ("my custom") !== attachedProposal ("foo"). So attached + name MUST
217
+ // NOT change. openspecChange SHOULD update via the activity-detector branch.
218
+ sendToolEvent(ws, "s1", { changeName: "bar" });
219
+ await new Promise((r) => setTimeout(r, 80));
220
+ s = server.sessionManager.get("s1");
221
+ expect(s?.attachedProposal).toBe("foo");
222
+ expect(s?.name).toBe("my custom");
223
+ expect(s?.openspecChange).toBe("bar");
224
+ });
225
+
226
+ it("§2A.2[4] already-converged state — no rename, no re-broadcast of redundant name", async () => {
227
+ sendToolEvent(ws, "s1", { changeName: "bar" });
228
+ await new Promise((r) => setTimeout(r, 80));
229
+ const before = server.sessionManager.get("s1");
230
+ expect(before?.attachedProposal).toBe("bar");
231
+
232
+ // Same changeName again — differentChangeDetected is false; no rename fires.
233
+ sendToolEvent(ws, "s1", { changeName: "bar" });
234
+ await new Promise((r) => setTimeout(r, 80));
235
+ const after = server.sessionManager.get("s1");
236
+ expect(after?.attachedProposal).toBe("bar");
237
+ expect(after?.name).toBe("bar");
185
238
  });
186
239
  });
187
240