@blackbelt-technology/pi-agent-dashboard 0.4.4 → 0.4.5-rc.1
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.
- package/AGENTS.md +38 -33
- package/README.md +1 -0
- package/docs/architecture.md +162 -4
- package/package.json +4 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/connection-suppress-auto-start.test.ts +97 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +81 -1
- package/packages/extension/src/bridge-context.ts +10 -0
- package/packages/extension/src/bridge.ts +22 -0
- package/packages/extension/src/connection.ts +29 -0
- package/packages/extension/src/server-auto-start.ts +16 -0
- package/packages/extension/src/session-sync.ts +14 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/cli-restart.test.ts +78 -0
- package/packages/server/src/__tests__/config-api.test.ts +9 -0
- package/packages/server/src/__tests__/is-activity-event.test.ts +78 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +164 -0
- package/packages/server/src/__tests__/last-activity-broadcast.test.ts +190 -0
- package/packages/server/src/__tests__/reattach-placement.test.ts +231 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +25 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +117 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +31 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +128 -0
- package/packages/server/src/__tests__/unread-persistence.test.ts +55 -0
- package/packages/server/src/__tests__/unread-trigger-wiring.test.ts +210 -0
- package/packages/server/src/__tests__/viewed-session-tracker.test.ts +93 -0
- package/packages/server/src/browser-gateway.ts +36 -0
- package/packages/server/src/cli.ts +70 -2
- package/packages/server/src/event-status-extraction.ts +98 -1
- package/packages/server/src/event-wiring.ts +70 -1
- package/packages/server/src/memory-session-manager.ts +34 -3
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/reattach-placement.ts +98 -0
- package/packages/server/src/restart-helper.ts +41 -2
- package/packages/server/src/routes/system-routes.ts +25 -1
- package/packages/server/src/server.ts +55 -3
- package/packages/server/src/session-scanner.ts +19 -0
- package/packages/server/src/viewed-session-tracker.ts +78 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +59 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +48 -1
- package/packages/shared/src/__tests__/no-bash-on-windows.test.ts +304 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +1 -1
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +107 -0
- package/packages/shared/src/__tests__/protocol.test.ts +11 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +92 -0
- package/packages/shared/src/browser-protocol.ts +25 -0
- package/packages/shared/src/config.ts +41 -0
- package/packages/shared/src/mdns-discovery.ts +32 -1
- package/packages/shared/src/platform/node-spawn.ts +30 -0
- package/packages/shared/src/protocol.ts +30 -1
- package/packages/shared/src/session-meta.ts +6 -0
- package/packages/shared/src/types.ts +19 -0
package/AGENTS.md
CHANGED
|
@@ -70,25 +70,25 @@ make clean # Destroy all cloned VMs
|
|
|
70
70
|
| File | Purpose |
|
|
71
71
|
|------|---------|
|
|
72
72
|
| `src/shared/protocol.ts` | Extension↔Server WebSocket messages. Phase-1 Extension UI System adds `ui_modules_list` / `ui_data_list` (extension → server) and `ui_management` (server → extension); these messages MUST stay in `ExtensionToServerMessage` / `ServerToExtensionMessage` unions or esbuild strips the switch cases in production. See change: add-extension-ui-modal. |
|
|
73
|
-
| `src/shared/browser-protocol.ts` | Server↔Browser WebSocket messages (all message types including PromptBus `prompt_request`/`prompt_dismiss`/`prompt_cancel` and Extension UI System Phase-1 `ui_modules_list`/`ui_data_list` (server → browser) + `ui_management` (browser → server) must be in the `ServerToBrowserMessage` / `BrowserToServerMessage` unions — `as any` switch cases are stripped by esbuild in production). **`prompt_request.metadata.toolCallId`** (change: fix-interactive-ui-reorder): optional field set by the bridge's `ctx.ui.{select, input, confirm, editor}` wrappers when the prompt is bound to a tool execution; consumed by the client reducer's `addInteractiveRequest` to pair the resulting `interactiveUi` row with its parent `toolResult`. The `metadata` field is typed `Record<string, unknown>`, so the addition is opt-in and forward/backward-compatible. |
|
|
74
|
-
| `src/shared/types.ts` | Data models (Session, Workspace, Event) |
|
|
75
|
-
| `src/shared/config.ts` | Shared config loader (`~/.pi/dashboard/config.json`). Includes `openspec: OpenSpecPollConfig` block (`pollIntervalSeconds` 5–3600, `maxConcurrentSpawns` 1–16, `changeDetection` `"mtime"\|"always"`, `jitterSeconds` 0–60) with clamping via `parseOpenSpecPollConfig`. See change: optimize-openspec-poll-burst |
|
|
73
|
+
| `src/shared/browser-protocol.ts` | Server↔Browser WebSocket messages (all message types including PromptBus `prompt_request`/`prompt_dismiss`/`prompt_cancel` and Extension UI System Phase-1 `ui_modules_list`/`ui_data_list` (server → browser) + `ui_management` (browser → server) must be in the `ServerToBrowserMessage` / `BrowserToServerMessage` unions — `as any` switch cases are stripped by esbuild in production). **`prompt_request.metadata.toolCallId`** (change: fix-interactive-ui-reorder): optional field set by the bridge's `ctx.ui.{select, input, confirm, editor}` wrappers when the prompt is bound to a tool execution; consumed by the client reducer's `addInteractiveRequest` to pair the resulting `interactiveUi` row with its parent `toolResult`. The `metadata` field is typed `Record<string, unknown>`, so the addition is opt-in and forward/backward-compatible. **`SessionViewBrowserMessage` / `SessionUnviewBrowserMessage`** (change: session-card-unread-stripes): browser → server messages declaring the currently-displayed session id (`/session/:id`). Both must stay in the `BrowserToServerMessage` union. The browser is required to re-send `session_view` for the active session on every WebSocket reconnect (handled by `useViewDispatcher`). |
|
|
74
|
+
| `src/shared/types.ts` | Data models (Session, Workspace, Event). **`DashboardSession.lastActivityAt?: number`** (epoch ms) is server-stamped on activity events via `isActivityEvent` in `event-status-extraction.ts`; cold-start seeded from `events.jsonl` mtime in `session-scanner.ts`; consumed by client `selectBadgeTimestamp` to render the session-card relative-time badge as time-since-last-activity instead of time-since-spawn. NOT persisted to `.meta.json`. See change: session-card-last-activity-badge. **`DashboardSession.unread?: boolean`** (change: session-card-unread-stripes): server-managed per-session unread bit. Set to `true` by `event-wiring.ts` when `isUnreadTrigger(...)` fires AND no browser is currently viewing the session AND the event is not part of a replay. Cleared when any browser sends `session_view`. Persisted to `.meta.json` so unread sessions stay flagged across server restarts. Bridges SHALL NOT send this field. Consumed by `SessionCard.tsx::getCardPulseClass` to render the cyan-stripes (`card-unread-pulse`, Tailwind `cyan-400`) decoration with lower priority than `card-input-pulse` (purple) and `card-working-pulse` (yellow). |
|
|
75
|
+
| `src/shared/config.ts` | Shared config loader (`~/.pi/dashboard/config.json`). Exports `ReattachPlacement = "preserve" | "streaming-only" | "always"` + `parseReattachPlacement(raw)` validator (default `"always"`); the `reattachPlacement` config field controls how the server places re-registering bridges in `sessionOrder` after a dashboard restart. See change: reattach-move-to-front. Includes `openspec: OpenSpecPollConfig` block (`pollIntervalSeconds` 5–3600, `maxConcurrentSpawns` 1–16, `changeDetection` `"mtime"\|"always"`, `jitterSeconds` 0–60) with clamping via `parseOpenSpecPollConfig`. See change: optimize-openspec-poll-burst |
|
|
76
76
|
| `src/shared/semaphore.ts` | Tiny FIFO semaphore (`createSemaphore(max)` → `{run, setMax, size}`). Used by `directory-service.ts` to cap concurrent `openspec` CLI spawns. Supports live resize via `setMax(n)` for runtime reconfig. |
|
|
77
|
-
| `src/extension/bridge.ts` | Main extension entry point (composes sync/tracker/flow modules, tracks `isAgentStreaming` in persistent BridgeState). **Invariant**: bridge code MUST NOT call `pi.newSession(...)` / `ctx.fork(...)` / `ctx.switchSession(...)` — enforced by `packages/extension/src/__tests__/no-session-replacement-calls.test.ts`. pi 0.69+ invalidates captured `pi`/`ctx` after these calls; the bridge re-captures state in `session_start` keyed on `event.reason ∈ {"new","fork","resume"}`. **PromptBus patch site**: patches `ctx.ui.select/input/confirm/editor/multiselect` in `session_start`; the TUI adapter handles `select/input/confirm/editor` only. `multiselect` is bridge-attached (pi has no native API) and routed exclusively through the bus to the `DashboardDefaultAdapter`'s browser dialog — decoded via `decodeMultiselectAnswer`. There is intentionally NO TUI adapter arm for multiselect: pi 0.70 RPC mode's `ctx.ui.custom` is a no-op, so any TUI arm awaiting it would auto-cancel the dashboard render in <1s (changes: fix-multiselect-auto-cancel-on-dashboard, fix-multiselect-tui-arm-self-cancel; regression-pinned by `no-tui-multiselect-arm-regression.test.ts`). **Per-message-fork entryId stamping** (change: fix-per-message-fork): `message_start` events now stamp a `nonce` (no entryId, since the user entry isn't persisted yet on pi 0.69+); `message_end` enrichment is deferred via `setTimeout(0)` (NOT `queueMicrotask`, which resolves inside pi's awaited extension dispatcher and misses `appendMessage`); a wrapped `ctx.sessionManager.appendMessage` emits `entry_persisted { entryId, nonce }` so the client reducer can back-fill the user-message bubble's `entryId`. |
|
|
77
|
+
| `src/extension/bridge.ts` | **`hasRegisteredOnce: boolean`** local (default `false`, synced through `BridgeContext`) flips to `true` after the first `sendStateSync`; consumed by `session-sync.ts` to tag the `session_register` message with `registerReason: "spawn" | "reattach"` so the server can apply the configured `reattachPlacement` policy on dashboard-restart reattaches. See change: reattach-move-to-front. Main extension entry point (composes sync/tracker/flow modules, tracks `isAgentStreaming` in persistent BridgeState). **Invariant**: bridge code MUST NOT call `pi.newSession(...)` / `ctx.fork(...)` / `ctx.switchSession(...)` — enforced by `packages/extension/src/__tests__/no-session-replacement-calls.test.ts`. pi 0.69+ invalidates captured `pi`/`ctx` after these calls; the bridge re-captures state in `session_start` keyed on `event.reason ∈ {"new","fork","resume"}`. **PromptBus patch site**: patches `ctx.ui.select/input/confirm/editor/multiselect` in `session_start`; the TUI adapter handles `select/input/confirm/editor` only. `multiselect` is bridge-attached (pi has no native API) and routed exclusively through the bus to the `DashboardDefaultAdapter`'s browser dialog — decoded via `decodeMultiselectAnswer`. There is intentionally NO TUI adapter arm for multiselect: pi 0.70 RPC mode's `ctx.ui.custom` is a no-op, so any TUI arm awaiting it would auto-cancel the dashboard render in <1s (changes: fix-multiselect-auto-cancel-on-dashboard, fix-multiselect-tui-arm-self-cancel; regression-pinned by `no-tui-multiselect-arm-regression.test.ts`). **Per-message-fork entryId stamping** (change: fix-per-message-fork): `message_start` events now stamp a `nonce` (no entryId, since the user entry isn't persisted yet on pi 0.69+); `message_end` enrichment is deferred via `setTimeout(0)` (NOT `queueMicrotask`, which resolves inside pi's awaited extension dispatcher and misses `appendMessage`); a wrapped `ctx.sessionManager.appendMessage` emits `entry_persisted { entryId, nonce }` so the client reducer can back-fill the user-message bubble's `entryId`. |
|
|
78
78
|
| `src/extension/bridge-context.ts` | Shared mutable state type + helpers for bridge modules |
|
|
79
|
-
| `src/extension/session-sync.ts` | Session register, replay, and switch/fork handling |
|
|
79
|
+
| `src/extension/session-sync.ts` | Session register, replay, and switch/fork handling. `sendStateSync` tags `session_register.registerReason` as `"spawn"` on first invocation per process and `"reattach"` thereafter, flipping `bc.hasRegisteredOnce`. `handleSessionChange` (new/fork/resume — fresh sessionId) ALWAYS tags `"spawn"` regardless of the flag. See change: reattach-move-to-front. |
|
|
80
80
|
| `src/extension/model-tracker.ts` | Model/thinking-level/git/name change detection |
|
|
81
81
|
| `src/extension/flow-event-wiring.ts` | Flow event listener registration (flow:* → event_forward) |
|
|
82
|
-
| `src/extension/connection.ts` | WebSocket with exponential backoff |
|
|
82
|
+
| `src/extension/connection.ts` | WebSocket with exponential backoff. **Auto-start suppression** (change: fix-restart-bridge-auto-start-race): exposes `pauseAutoStart(ms)` (idempotent extend-only) + `shouldSuppressAutoStart()`. The bridge calls `pauseAutoStart(quiesceMs)` on receipt of `server_restarting`; `server-auto-start.ts` consults `shouldSuppressAutoStart()` and skips the spawn step while the window is active so bridges never race the `restart-helper.ts` orchestrator. Discovery + reconnection are NOT suppressed. |
|
|
83
83
|
| `src/extension/server-probe.ts` | TCP probe to detect running server |
|
|
84
84
|
| `src/shared/server-identity.ts` | Identity-verified health check (`isDashboardRunning`) replacing bare TCP probes |
|
|
85
|
-
| `src/shared/mdns-discovery.ts` | mDNS advertise/discover/browse for `_pi-dashboard._tcp` services |
|
|
85
|
+
| `src/shared/mdns-discovery.ts` | mDNS advertise/discover/browse for `_pi-dashboard._tcp` services. Exports `pickBestHost(service)` which returns `service.host` only when it matches the DNS-safe pattern `/^[A-Za-z0-9.-]+$/` (no leading/trailing hyphen); otherwise falls back to the first IPv4 address in `service.addresses`, then to any address, finally to the original host. Bonjour on macOS advertises the OS computer-name verbatim (e.g. `"MacBook 242"`) which contains spaces — without this fallback, saved known-server entries are unresolvable in the browser. `serviceToServer` calls `pickBestHost` so discovered servers always carry a host the browser can resolve. |
|
|
86
86
|
| `src/extension/server-launcher.ts` | Auto-start server as detached process; captures **both stdout AND stderr** to `~/.pi/dashboard/server.log` (append mode) by passing `stdoutFd: logFd` alongside `logFd` — parity with `pi-dashboard start`'s `stdio: ["ignore", logFd, logFd]`. Exports pure `buildSpawnDetachedOptions` and `buildReadyTimeoutMessage`; the latter appends a `nodejs/node#58515` upgrade hint when `isKnownBadNode(process.version)` is true. |
|
|
87
87
|
| `src/extension/command-handler.ts` | Command routing: `!`/`!!` bash, `/compact`, slash commands |
|
|
88
88
|
| `src/extension/prompt-expander.ts` | Slash command → prompt template expansion (supports colon-to-hyphen aliasing: `/opsx:cmd` → `opsx-cmd.md`) |
|
|
89
89
|
| `src/extension/dev-build.ts` | Dev build-on-reload helper (client build + server shutdown) |
|
|
90
90
|
| `src/extension/server-auto-start.ts` | mDNS-first discovery → health check fallback → auto-start with concurrent launch detection |
|
|
91
|
-
| `src/shared/session-meta.ts` | Session metadata sidecar (.meta.json) read/write helpers |
|
|
91
|
+
| `src/shared/session-meta.ts` | Session metadata sidecar (.meta.json) read/write helpers. **`SessionMeta.unread?: boolean`** (change: session-card-unread-stripes) mirrors `DashboardSession.unread`; persisted by `metaPersistence.save(...)` in `server.ts onChange` and restored by `session-scanner.ts::sessionFromMeta` so the unread bit survives server reboot. Backwards compatible — absent field reads as `undefined` (treated as `false`). |
|
|
92
92
|
| `src/extension/process-metrics.ts` | Lightweight CPU/memory/event-loop metrics collector for heartbeats |
|
|
93
93
|
| `src/extension/process-scanner.ts` | Child process detection via ps + PGID tracking (leaf-only, grandchild recursion) and PGID-based kill |
|
|
94
94
|
| `src/client/components/ProcessList.tsx` | Session card process list with elapsed time and red ✕ kill button |
|
|
@@ -145,7 +145,7 @@ make clean # Destroy all cloned VMs
|
|
|
145
145
|
| `src/server/routes/file-routes.ts` | REST routes: file read, browse (with `detect=0\|1` opt-in classifier), browse-flags (bulk classifier), browse-mkdir, readme, pinned-dirs. See change: split-browse-flags |
|
|
146
146
|
| `src/server/routes/openspec-routes.ts` | REST routes: openspec-archive, pi-resources, pi-resource-file |
|
|
147
147
|
| `src/server/routes/system-routes.ts` | REST routes: config, health, shutdown, tunnel, editors |
|
|
148
|
-
| `src/server/event-wiring.ts` | Pi gateway → browser gateway event forwarding (replay suppression with `skipReplayInsert` dedup, flows refresh dedup, context usage extraction). Phase-1 Extension UI System: caches `ui_modules_list` on `Session.uiModules` and broadcasts; caches `ui_data_list` on `Session.uiDataMap[event]` with a per-event item cap (default 1000, last-write-wins on overflow) and broadcasts. Phase-2: `ext_ui_decorator` switch arm caches descriptors under `Session.uiDecorators[`${kind}:${namespace}:${id}`]` (upsert, or delete when `removed: true`) and broadcasts the message verbatim to subscribers; deleting an absent key is a no-op but still broadcasts. See changes: add-extension-ui-modal, add-extension-ui-decorations. |
|
|
148
|
+
| `src/server/event-wiring.ts` | Pi gateway → browser gateway event forwarding (replay suppression with `skipReplayInsert` dedup, flows refresh dedup, context usage extraction). Phase-1 Extension UI System: caches `ui_modules_list` on `Session.uiModules` and broadcasts; caches `ui_data_list` on `Session.uiDataMap[event]` with a per-event item cap (default 1000, last-write-wins on overflow) and broadcasts. Phase-2: `ext_ui_decorator` switch arm caches descriptors under `Session.uiDecorators[`${kind}:${namespace}:${id}`]` (upsert, or delete when `removed: true`) and broadcasts the message verbatim to subscribers; deleting an absent key is a no-op but still broadcasts. **Last-activity stamping** (change: session-card-last-activity-badge): every live (non-replay) `event_forward` whose `eventType` passes `isActivityEvent(...)` (`event-status-extraction.ts` allowlist — `prompt_send`, `message_*`, `turn_end`, `tool_execution_*`, `agent_*`, `bash_output`, `flow_*`, `architect_*`) updates `session.lastActivityAt = Date.now()` in memory and broadcasts at most once per 30 s per session via `lastActivityBroadcastAt: Map<sessionId, ms>`; the map entry is dropped on `session_unregister` so a subsequent re-register does not silently suppress its first broadcast. See changes: add-extension-ui-modal, add-extension-ui-decorations, session-card-last-activity-badge. **Unread-trigger evaluation** (change: session-card-unread-stripes): right after the `extractSessionUpdates` block, snapshots `{status, currentTool}` before/after and calls `isUnreadTrigger(eventType, before, after, payload)`; if true AND `viewedSessionTracker.isViewedByAnyone(sessionId) === false` AND `!replayingSessions.has(sessionId)`, stamps `session.unread = true` and broadcasts `session_updated`. The `viewedSessionTracker` dep is optional on `EventWiringDeps` for backward compatibility — wiring is opt-in. |
|
|
149
149
|
| `src/server/idle-timer.ts` | Auto-shutdown idle timer with sleep-wake resilience |
|
|
150
150
|
| `src/server/session-bootstrap.ts` | Startup session discovery and OpenSpec polling init |
|
|
151
151
|
| `src/server/pi-gateway.ts` | Extension WebSocket gateway (port 9999) |
|
|
@@ -202,13 +202,14 @@ make clean # Destroy all cloned VMs
|
|
|
202
202
|
| `src/client/components/SortablePinnedGroup.tsx` | Drag-to-reorder wrapper for pinned directory groups |
|
|
203
203
|
| `src/server/preferences-store.ts` | Global UI preferences (pinned dirs, session order) in `preferences.json` |
|
|
204
204
|
| `src/server/meta-persistence.ts` | Per-session debounced `.meta.json` writer |
|
|
205
|
-
| `src/server/session-scanner.ts` | Startup session discovery by scanning `~/.pi/agent/sessions
|
|
205
|
+
| `src/server/session-scanner.ts` | Startup session discovery by scanning `~/.pi/agent/sessions/`. Each restored `DashboardSession` has `lastActivityAt` cold-start-seeded from the `events.jsonl` mtime (one `fs.statSync` per session, defensive try/catch — returns `undefined` on error so the badge falls back to `startedAt`). See change: session-card-last-activity-badge. The restored session also carries `unread` from `meta.unread` so unread sessions stay flagged across server restart. The `server.ts:273-279` cold-start "force status=ended" override only mutates `status` and `endedAt`, leaving `unread` intact — a session that was unread when the server stopped is still unread when it starts back up, even before its bridge reattaches. See change: session-card-unread-stripes. |
|
|
206
206
|
| `src/server/migrate-persistence.ts` | One-time migration from `sessions.json` + `state.json` to `.meta.json` |
|
|
207
207
|
| `src/server/session-order-manager.ts` | Per-cwd session ordering with persistence. **Move-to-front semantic** (change: top-of-tier-on-status-change): exports `moveToFront(cwd, sessionId)` — idempotent `remove + unshift` used by `server.ts onChange`'s ended→alive user-intent branch so the just-resumed card always surfaces at the top of the alive tier, even on repeated end→resume cycles. Bridge auto-reattach short-circuits before any mutation via `pendingResumeIntents.consume()`. |
|
|
208
208
|
| `src/server/directory-service.ts` | Server-side session discovery, event loading, and OpenSpec polling. Uses mtime-gated per-directory cache (`DirCache`), shared FIFO semaphore, and deterministic per-cwd `phaseOffsetMs(cwd, jitterSeconds)` jitter (FNV-1a 32-bit hash). **Per-change watch set** (`perChangeArtifactPaths`) covers `<change>/`, `tasks.md`, `proposal.md`, `design.md`, plus `specs/`, every immediate `specs/<cap>/`, and every `specs/<cap>/spec.md` — `readdirSync` of `specs/` is try/catch-wrapped so missing dirs yield an empty fan-out (change: fix-openspec-specs-mtime-gate-blind-spot, which also wires `createFsSpecsProbeFactory(cwd)` into `buildOpenSpecData` as a second probe-factory argument so multi-spec authoring lights up green even when the gate misfires). **TOCTOU-safe stamping** (change: fix-openspec-mtime-gate-toctou): the per-change status loop captures `preCallMtime` *before* awaiting `runOpenSpecStatus` and stamps THAT value into the cache; if the post-call effective mtime differs, the entry is racy and the cache is left untouched (next gated tick re-polls). DEBUG-gated `console.warn` cites this change name when the discard branch fires. **Refresh contract**: `refreshOpenSpec(cwd)` is the user-clicked path and bypasses the gate via `pollOne(cwd, true)` — it's the manual escape hatch when the gate's heuristic is wrong. `pollDirectoryGated`, `onDirectoryAdded`, and the post-archive refresh in `handleOpenSpecBulkArchive` all call `pollOne(cwd, false)` so periodic / internal paths stay O(1) status spawns. `reconfigurePolling(cfg)` applies live config changes without a restart. Pi-resources scan lives on its own 5×-interval timer so it doesn't stack with the openspec burst. See changes: fix-openspec-specs-mtime-gate-blind-spot, fix-openspec-mtime-gate-toctou, fix-openspec-mtime-gate-blind-spots, optimize-openspec-poll-burst |
|
|
209
209
|
| `src/server/pending-fork-registry.ts` | Tracks pending fork operations for session placement |
|
|
210
210
|
| `src/server/pending-resume-registry.ts` | Queues prompts for auto-resume of ended sessions |
|
|
211
|
-
| `src/server/pending-resume-intent-registry.ts` | In-memory `Map<sessionId, timestamp>` (default 60 s TTL, lazy expiry on read). `record(id)` is called by `handleResumeSession` (WS) and the REST `POST /api/session/:id/resume` handler immediately before `spawnPiSession`; `consume(id)` is called by `server.ts`'s `sessionManager.onChange` hook in the ended→alive branch.
|
|
211
|
+
| `src/server/pending-resume-intent-registry.ts` | In-memory `Map<sessionId, timestamp>` (default 60 s TTL, lazy expiry on read). `record(id)` is called by `handleResumeSession` (WS) and the REST `POST /api/session/:id/resume` handler immediately before `spawnPiSession`; `consume(id)` is called by `server.ts`'s `sessionManager.onChange` hook in the ended→alive branch. **4-way intent contract** (change: reattach-move-to-front): when `consume` returns `null` (no user intent tagged) the branch checks `OnChangeContext.registerReason`; if `"reattach"` it applies the configured `reattachPlacement` policy via `reattach-placement.ts::applyReattachPolicy`, otherwise (legacy bridge or genuine spawn) it returns early without mutation. Registry intents (`"front"` / `"keep"`) always win over `registerReason` per spec. The `if (!order.includes(sessionId))` guard inside the branch keeps drag-to-resume's dropped slot intact when the intent is present. In-memory only — NOT persisted across server restarts. See changes: preserve-session-order-on-reboot, reattach-move-to-front. |
|
|
212
|
+
| `src/server/reattach-placement.ts` | Pure `decideReattachAction(policy, status)` + I/O `applyReattachPolicy(sessionId, cwd, policy, deps, priorStatus?)` for the bridge-reattach placement policy. Mapping: `"always"` → `moveToFront`; `"streaming-only"` → `moveToFront` iff `effectiveStatus === "streaming"` where `effectiveStatus = priorStatus ?? session.status`; `"preserve"` → no-op. **`priorStatus` is required for `streaming-only` to work**: `memory-session-manager.ts::register` unconditionally sets `status: "active"`, so without the prior value the policy would silently behave as `"preserve"`. Captured by `register()` from `existing?.status` BEFORE assembling the new session and forwarded via `OnChangeContext.priorStatus`. `applyReattachPolicy` is wired from `server.ts onChange` at TWO sites: (a) the existing ended→alive branch when the consumed registry intent is `null` and `ctx.registerReason === "reattach"`, and (b) a new alive→alive branch (`!isEnded && !wasEnded && ctx?.registerReason === "reattach"`) that handles the common case where the session was persisted as `"active"` so neither end-state transition fires. See change: reattach-move-to-front. |
|
|
212
213
|
| `src/server/json-store.ts` | Atomic JSON file read/write helpers |
|
|
213
214
|
| `src/server/process-manager.ts` | Session spawning: dispatches via `platform/spawn-mechanism.ts` `selectMechanism` → `tmux` / `wt` / `wsl-tmux` / `headless`. All mechanisms forward `sessionFile`/`mode` uniformly via `sessionFlagsToArgv`. Windows headless uses `spawnDetached` primitive with `detached: true` (PGID-equivalent via libuv) and stderr-to-file-fd (crash-visible). |
|
|
214
215
|
| `src/shared/platform/detached-spawn.ts` | Three primitives: `spawnDetached` (libuv-correct detached defaults on every OS), `waitForNoCrash` (negative liveness — did it survive the window?), `waitForReady` (positive liveness — did a probe turn true?). All spawn sites with long-lived detached children delegate here. `SpawnDetachedOptions` exposes optional `stdoutFd` (defaults to `"ignore"`) and `logFd` (stderr, defaults to `"ignore"`); the bridge server-launcher sets both to the same fd for parity with the CLI. |
|
|
@@ -218,7 +219,7 @@ make clean # Destroy all cloned VMs
|
|
|
218
219
|
| `src/shared/platform/spawn-mechanism.ts` | `SpawnMechanism` enum (`tmux`/`wt`/`wsl-tmux`/`headless`) + pure `selectMechanism({ platform, userStrategy, electronMode, available })` selector. `buildWtArgs` builds argv for Windows Terminal `new-tab`. `sessionFlagsToArgv` is the uniform flag builder every mechanism MUST call. |
|
|
219
220
|
| `src/shared/platform/process-identify.ts` | `findPidByMarker` + `isProcessLikePi` + `isPiCommandLine` — consolidates the three `process.platform === "win32"` branches that previously lived inside `session-action-handler.ts`. Windows stubs are documented (command-line lookup goes via `headlessPidRegistry` instead). |
|
|
220
221
|
| `src/shared/platform/process.ts` | **Sole source of process termination + liveness primitives**: `isProcessAlive(pid)` (signal 0), `killProcess(pid, {timeoutMs})` (Windows `taskkill /F /T /PID`, POSIX `SIGTERM` → wait → `SIGKILL` tree kill), `killPidWithGroup(pid, sig)` (POSIX `kill(-pid, sig)`, Windows direct kill). Every `process.kill(...)` call outside this file is banned by `no-direct-process-kill.test.ts`. See change: route-kill-paths-through-platform. |
|
|
221
|
-
| `src/shared/platform/node-spawn.ts` | **Sole source of `node --import <loader> <entry>` argv construction**: `toFileUrl(pathOrUrl)` (idempotent path → file:// URL, handles Windows drive letters on POSIX hosts), `spawnNodeScript(opts)` (wraps both loader and entry positions as file:// URLs before delegating to `platform/exec.ts::spawn`). Every `spawn(process.execPath, ["--import", ...])` call outside this file with raw path arguments is banned by `no-raw-node-import.test.ts`. Fixes `ERR_UNSUPPORTED_ESM_URL_SCHEME` on non-C: Windows drives (e.g. `B:\`) where Node's drive-letter heuristic for entry-script paths has known gaps. See change: fix-windows-entry-script-url. |
|
|
222
|
+
| `src/shared/platform/node-spawn.ts` | **Sole source of `node --import <loader> <entry>` argv construction**: `toFileUrl(pathOrUrl)` (idempotent path → file:// URL, handles Windows drive letters on POSIX hosts), `spawnNodeScript(opts)` (wraps both loader and entry positions as file:// URLs before delegating to `platform/exec.ts::spawn`). Every `spawn(process.execPath, ["--import", ...])` call outside this file with raw path arguments is banned by `no-raw-node-import.test.ts`. Fixes `ERR_UNSUPPORTED_ESM_URL_SCHEME` on non-C: Windows drives (e.g. `B:\`) where Node's drive-letter heuristic for entry-script paths has known gaps. See change: fix-windows-entry-script-url. **Jiti version contract** (change: fix-electron-windows-installer-and-server-bootstrap, Defect 2): `shouldUrlWrapEntry()`'s Windows-non-tsx arm assumes the jiti loader is from `@mariozechner/pi-coding-agent@0.70.x` (jiti 2.x with the `file:///` triple-slash URL handling fix). Newer jiti versions (e.g. jiti 2.6.5 in pi 0.71.x) misnormalize triple-slash URLs and break the contract. The contract is defended by Defect 1's fix (managed-dir population from the offline cacache pins pi to 0.70.0); regression-pinned by `node-spawn-jiti-contract.test.ts` which asserts `offline-packages.json` keeps the pin in the supported `0.70.x` range AND the docstring documents the contract. |
|
|
222
223
|
| `src/shared/__tests__/no-raw-node-import.test.ts` | Repo-level lint: scans `packages/*/src/` (excluding `platform/node-spawn.ts` + `resolve-jiti.ts` + `__tests__/`) for `spawn(...)` argv with `"--import"` / `"--loader"` followed by raw identifiers not wrapped in `toFileUrl(...)` / `pathToFileURL(...)`. Mirrors `no-direct-process-kill.test.ts` pattern. |
|
|
223
224
|
| `src/shared/__tests__/no-direct-process-kill.test.ts` | Repo-level lint: scans `packages/*/src/` (excluding platform/ and `__tests__/`) for `process.kill(` calls and fails with file:line if any are found. Mirrors `no-direct-child-process.test.ts`. |
|
|
224
225
|
| `src/shared/__tests__/bootstrap/` | In-memory bootstrap resolution harness (memfs-backed). `harness.ts` (withFakeEnv + layer), `fixtures/` (managed/npm-g/electron/dev-monorepo/settings-json layouts), `assertions.ts` (snapshotTrail + snapshotSettingsDelta with `<HOME>` / `<NPM_ROOT>` normalization), `scenarios.ts` (1080-cell cube: platform × dash × pi × settings × env), `scenarios-skipped.ts` (bulk-skip manifest with documented reasons), `cube.test.ts` (fail-closed sweep), `families/*.test.ts` (30+ registered scenario cells across A-K). Run via `npm run test:bootstrap`. See change: bootstrap-resolution-harness. |
|
|
@@ -227,7 +228,8 @@ make clean # Destroy all cloned VMs
|
|
|
227
228
|
| `src/server/editor-proxy.ts` | Reverse proxy for `/editor/:id/*` to code-server instances |
|
|
228
229
|
| `src/server/editor-detection.ts` | Auto-detect code-server/openvscode-server binary on PATH |
|
|
229
230
|
| `src/server/routes/editor-routes.ts` | REST routes: editor start, stop, heartbeat, status, detect |
|
|
230
|
-
| `src/server/event-status-extraction.ts` | Extracts session status/tool updates from events (incl. flow metadata) |
|
|
231
|
+
| `src/server/event-status-extraction.ts` | Extracts session status/tool updates from events (incl. flow metadata). Hosts two pure classifiers consumed by `event-wiring.ts`: `isActivityEvent(eventType)` (allowlist driving `lastActivityAt` stamping; see change: session-card-last-activity-badge) and `isUnreadTrigger(eventType, before, after, payload)` (returns true on `streaming→idle\|active`, on `currentTool→"ask_user"`, and on `agent_end` with truthy `payload.error`; see change: session-card-unread-stripes). |
|
|
232
|
+
| `src/server/viewed-session-tracker.ts` | In-memory `Map<sessionId, Set<WebSocket>>` registry of which browser has which session displayed (`/session/:id`). Created by `browser-gateway.ts`, exposed on `BrowserGateway.viewedSessionTracker`, and threaded into `wireEvents({ ..., viewedSessionTracker })`. `view`/`unview` are called from the `session_view`/`session_unview` switch arms; `unviewAll(ws)` is called on every WS `close` so disconnected browsers cannot hold sessions in the viewed state. `isViewedByAnyone(sessionId)` gates the unread-trigger stamp in `event-wiring.ts`. Read state is GLOBAL across browsers — mirrors mail/Slack. In-memory only. See change: session-card-unread-stripes. |
|
|
231
233
|
| `src/server/headless-pid-registry.ts` | Maps headless child PIDs to session IDs |
|
|
232
234
|
| `src/server/auth.ts` | OAuth2 authentication: provider registry, JWT helpers, user allowlist |
|
|
233
235
|
| `src/server/provider-auth-handlers.ts` | Pi provider OAuth handlers (Anthropic, Codex, GitHub Copilot, Gemini CLI, Antigravity) |
|
|
@@ -262,8 +264,8 @@ make clean # Destroy all cloned VMs
|
|
|
262
264
|
| `public/manifest.json` | PWA web app manifest for installability |
|
|
263
265
|
| `public/sw.js` | Minimal service worker for PWA installability |
|
|
264
266
|
| `src/client/components/ZrokInstallGuide.tsx` | OS-aware zrok installation guide view (macOS/Linux/Windows) |
|
|
265
|
-
| `src/server/cli.ts` | CLI entry point with subcommands (start/stop/restart/status); `findPortHolders` is cross-platform (netstat/taskkill on Windows, lsof on Unix) and `server.log` is opened append-mode with timestamped headers. The local post-install `registry.rescan("pi")` block was removed and ownership of the post-install rescan + force-refresh moved to the centralized `bootstrapState.subscribe` hook in `server.ts`. See change: fix-openspec-buttons-after-bootstrap-install. |
|
|
266
|
-
| `src/server/restart-helper.ts` | Cross-platform `/api/restart` orchestrator: spawns a detached `node -e` child using only Node built-ins (net, http) — no sh/lsof/curl dependency; exports pure `buildOrchestratorScript(params)` for testing |
|
|
267
|
+
| `src/server/cli.ts` | CLI entry point with subcommands (start/stop/restart/status); `findPortHolders` is cross-platform (netstat/taskkill on Windows, lsof on Unix) and `server.log` is opened append-mode with timestamped headers. The local post-install `registry.rescan("pi")` block was removed and ownership of the post-install rescan + force-refresh moved to the centralized `bootstrapState.subscribe` hook in `server.ts`. See change: fix-openspec-buttons-after-bootstrap-install. **`cmdRestart(config, injected?)`** (change: fix-restart-bridge-auto-start-race): probes `isDashboardRunning(port)`; if up, POSTs `/api/restart` with `{dev}` and exits, delegating the entire stop/start to `restart-helper.ts`'s detached orchestrator; if down or HTTP fails, falls back to local `cmdStop()` + `cmdStart()`. Eliminates the in-process race where `cmdStop` killed the daemon and bridges' auto-start beat the subsequent `cmdStart`'s `isServerRunning` check (silently early-returning, leaving the user offline). The optional `injected` arg is for unit tests — production callers always use the real defaults. |
|
|
268
|
+
| `src/server/restart-helper.ts` | Cross-platform `/api/restart` orchestrator: spawns a detached `node -e` child using only Node built-ins (net, http) — no sh/lsof/curl dependency; exports pure `buildOrchestratorScript(params)` for testing. **Explicit prior-daemon kill** (change: fix-restart-bridge-auto-start-race): the embedded script reads `~/.pi/dashboard/dashboard.pid`, sends `SIGTERM` to the recorded PID if alive, polls for exit (3 s deadline), then `SIGKILL`. Removes the "wait for self-exit" ambiguity that let bridges race the orchestrator before this change. The `portFree` poll is reduced from 10 s to 5 s since step 0 already guarantees the previous server is dead. |
|
|
267
269
|
| `src/shared/resolve-jiti.ts` | Resolves pi's jiti register hook as a `file://` URL (required for `node --import` on Windows); exports pure `buildJitiRegisterUrl(pkgJsonPath)` helper and `resolveJitiFromAnchor(anchorPath)` for managed-install/system-pi callers |
|
|
268
270
|
| `src/shared/platform/paths.ts` | OS-aware path primitives: `normalizePath`, `samePath` (filesystem-level equality), `parsePathInput` (picker input), `withTrailingSep`, `isFilesystemRoot`. All accept optional trailing `platform: NodeJS.Platform` for testability. Windows multi-drive invariant: A:\, B:\, C:\ never merge; bare `B:` input treated as drive root, not cwd-relative. Exported from `platform/index.ts` as `paths.*` namespace alongside `git.*`, `openspec.*`, `npm.*` |
|
|
269
271
|
| `src/client/lib/session-grouping.ts` | `inferPlatform(samples)` heuristic (backslash/drive-letter = Windows, leading `/` = POSIX) + `groupSessionsByDirectory` that uses `normalizePath`-keyed Maps so sessions group under their pinned folder across separator/case/trailing drift |
|
|
@@ -306,7 +308,7 @@ make clean # Destroy all cloned VMs
|
|
|
306
308
|
| `src/client/components/MobileShell.tsx` | Two-panel mobile shell with slide transitions and swipe-back |
|
|
307
309
|
| `src/client/components/MobileActionMenu.tsx` | Kebab menu for session actions on mobile (includes OpenSpec commands) |
|
|
308
310
|
| `src/client/components/MobileOverlay.tsx` | Hamburger button and sidebar overlay for mobile |
|
|
309
|
-
| `src/client/components/SessionHeader.tsx` | Session header with OpenSpec attach/detach, flow launcher, MobileAttachButton. **Mobile** branch additionally renders a read-only `mobile-header-attached-chip` (paperclip + `session.attachedProposal`) between the title and the `MobileAttachButton` so attach/detach state is visible without opening the popover. See change: fix-mobile-attach-proposal-display. **Two-row mobile layout** (change: fix-mobile-header-and-orientation): when `session.attachedProposal` is non-empty, `MobileHeader` becomes a `flex-col` with row 1 = back+title+attach+kebab and row 2 = the existing chip span (now without the `max-w-[55%]` constraint since it no longer competes with the title for horizontal space). When `attachedProposal` is null/empty the header collapses to a single-row container exactly as before — no empty row 2 reserved. **Attached-proposal artifact summary** (change: add-attached-proposal-header-summary): both the desktop branch and `MobileHeader` look up `session.attachedProposal` in `openspecChanges` and, when found, render the existing `ArtifactLettersButton` (P/D/T/S letters colored by status, single button → opens proposal artifact via `onReadArtifact` prop threaded from `App.tsx → useContentViews`) plus a `(completedTasks/totalTasks)` counter (`data-testid="attached-proposal-task-counter"`, only when `totalTasks > 0`). Surface is gated on the explicit `attachedProposal` only — auto-detected `openspecChange` does not trigger it. |
|
|
311
|
+
| `src/client/components/SessionHeader.tsx` | Session header with OpenSpec attach/detach, flow launcher, MobileAttachButton. **`onResume?: (mode: "continue" | "fork") => void`** prop (change: resume-button-in-session-header): when the viewed session has `status === "ended"` AND a non-empty `sessionFile` AND a non-null `onResume` callback, the desktop toolbar replaces the dimmed elapsed-duration span with a green Resume pill (`mdiPlayCircleOutline`) + blue Fork pill (`mdiSourceFork`) that mirror the sidebar `SessionCard.tsx:544-563` visual language. Both buttons carry `data-testid="header-{resume,fork}-button"`, are `disabled={!!session.resuming}`, and invoke `onResume("continue")` / `onResume("fork")` respectively. `App.tsx` wires the prop to `(mode) => handleResumeSession(selectedId, mode)` so the new affordance reuses the existing `resume_session` WebSocket protocol with no server-side changes. Mobile path is unchanged — `MobileActionMenu`'s kebab still owns Resume on mobile, and the desktop pills are gated behind `isMobile === false`. **Mobile** branch additionally renders a read-only `mobile-header-attached-chip` (paperclip + `session.attachedProposal`) between the title and the `MobileAttachButton` so attach/detach state is visible without opening the popover. See change: fix-mobile-attach-proposal-display. **Two-row mobile layout** (change: fix-mobile-header-and-orientation): when `session.attachedProposal` is non-empty, `MobileHeader` becomes a `flex-col` with row 1 = back+title+attach+kebab and row 2 = the existing chip span (now without the `max-w-[55%]` constraint since it no longer competes with the title for horizontal space). When `attachedProposal` is null/empty the header collapses to a single-row container exactly as before — no empty row 2 reserved. **Attached-proposal artifact summary** (change: add-attached-proposal-header-summary): both the desktop branch and `MobileHeader` look up `session.attachedProposal` in `openspecChanges` and, when found, render the existing `ArtifactLettersButton` (P/D/T/S letters colored by status, single button → opens proposal artifact via `onReadArtifact` prop threaded from `App.tsx → useContentViews`) plus a `(completedTasks/totalTasks)` counter (`data-testid="attached-proposal-task-counter"`, only when `totalTasks > 0`). Surface is gated on the explicit `attachedProposal` only — auto-detected `openspecChange` does not trigger it. |
|
|
310
312
|
| `src/client/hooks/useMobile.tsx` | `MobileProvider` + `useMobile()` hook. Predicate is **width OR height** — the wrapped `useMediaQuery("(max-width: 767px), (max-height: 599px)")` flips to mobile whenever the viewport is < 768px wide OR < 600px tall. The height arm catches landscape phones (iPhone 14 in landscape is 844×390, Pixel 8 landscape 915×412) so they get the single-panel mobile layout instead of the cramped desktop two-panel one. Documented side effect: shrinking a desktop window to <600px tall also enters mobile mode (pinned by a regression test in `useMobile.test.tsx`). See change: fix-mobile-header-and-orientation. |
|
|
311
313
|
| `src/client/hooks/useSwipeBack.ts` | iOS-style left-edge swipe-back gesture (40px edge zone, document-level listeners) |
|
|
312
314
|
| `src/client/components/ChatView.tsx` | Chat message view with scroll-lock: pauses auto-scroll when user scrolls up, floating scroll-to-bottom button, per-session scroll position persistence. Race-safe across multi-batch `event_replay`: a `markProgrammatic()` helper raises `programmaticScroll` for ~150 ms around every self-initiated `scrollTo`, and `handleScroll` early-returns while the flag is set so the spurious onScroll that lags `scrollTo` (and would otherwise misread the now-grown `scrollHeight` as "user scrolled away") cannot flip `isNearBottom` during replay. See change: fix-chat-scroll-race-during-replay |
|
|
@@ -321,26 +323,27 @@ make clean # Destroy all cloned VMs
|
|
|
321
323
|
| `src/client/hooks/useSessionActions.ts` | Session action callbacks hook (send, abort, resume, spawn, etc.) |
|
|
322
324
|
| `src/client/hooks/useOpenSpecActions.ts` | OpenSpec action callbacks hook (refresh, archive, attach, detach); calls `clearAllContentViews` before opening preview |
|
|
323
325
|
| `src/client/hooks/useContentViews.ts` | Content view state + fetch (pi resources, readme, file preview); `clearAll()` resets all hook-owned states; `onBeforeOpen` callback for cross-component clearing |
|
|
324
|
-
| `src/client/lib/event-reducer.ts` | Event-sourced state reducer (delegates flow events to flow-reducer); extracts LLM errors from `agent_end` into `lastError`. **Assistant content-array reorder** (changes: fix-text-tool-render-order, fix-interactive-ui-reorder): on every `message_end` for `role:"assistant"`, the pure helper `reorderToolCardsForAssistantMessage(messages, content)` reorders the trailing rows so that text/thinking/toolCall blocks land in the same order as the model's `content[]` array. **Turn-boundary anchored window**: walks `messages[]` backwards from the tail collecting every row whose role is NOT a hard turn boundary (`TURN_BOUNDARY_ROLES = {user, turnSeparator, commandFeedback, rawEvent}`). Window = `[boundaryIdx + 1 ..]` — prior-turn rows separated by a hard boundary cannot leak in. **Pairing rule** (fix-interactive-ui-reorder): for each `toolCall` block, the helper claims the matching `toolResult` row AND any `interactiveUi` row whose `toolCallId` matches, emitting them as a `[toolResult, interactiveUi]` pair so `ask_user` and other PromptBus-routed tools render below their own assistant intro text. Uses `findLastUnclaimed` (most-recent first) so back-to-back assistant turns without a user response between them still claim the current message's rows correctly. **Hybrid unclaimed-row handling**: rows of "reorderable" roles (`assistant`, `toolResult`, `thinking`) that are unclaimed keep their **original suffix index** (protects prior-message rows that bled in when no boundary exists between back-to-back assistants); rows of "trailing" roles (`interactiveUi`, `bashOutput`) that are unclaimed are emitted AFTER claimed rows so free-floating ui dialogs (no `toolCallId`) sit below their tool card. Preserves React keyed reconciliation (`tool-${id}` / `ui-${id}`). Replay path inherits the fix because it routes through the same reducer. `addInteractiveRequest(state, requestId, method, params, toolCallId?)` accepts the optional 5th param so the pushed `interactiveUi` ChatMessage carries the `toolCallId`. |
|
|
326
|
+
| `src/client/lib/event-reducer.ts` | Event-sourced state reducer (delegates flow events to flow-reducer); extracts LLM errors from `agent_end` into `lastError`. **Streaming-text flush at tool start** (change: fix-streaming-text-vs-interactive-ui-order): on `tool_execution_start`, if `streamingText` is non-empty AND `streamingTextFlushed` is `false`, the pure helper `flushStreamingTextAsAssistantRow(state, timestamp)` pushes a permanent `role:"assistant"` row carrying the current `streamingText` BEFORE pushing the new `toolResult`, then clears `streamingText` and sets `streamingTextFlushed = true`. This collapses the bad-render window where a long-running tool (`npm test`, `ask_user`, subagent) would otherwise have its card sit above its describing prose for the entire tool runtime. The flushed row carries `entryId/nonce: undefined`; on `message_end` the reducer locates it via `findFlushedAssistantRowIndex(messages)` (tail-backwards scan with **hard upper bound at `TURN_BOUNDARY_ROLES`** to prevent R3 cross-message pollution from orphan rows whose `message_end` was dropped) and stamps `data.entryId` / `data.nonce` in place — NO duplicate row is pushed. The flag is reset on every assistant `message_start` AND every assistant `message_end` (R7 defense-in-depth: lifecycle equals "between message_start and message_end"). `message_update` arm skips its `streamingText = text` write when the flag is set, so an already-flushed prefix doesn't reappear in the streaming bubble below `messages[]`. Accepted tradeoff: in `[text, toolCall, text]`-shaped messages the second text block does not stream live (lands at `message_end` only). **Assistant content-array reorder** (changes: fix-text-tool-render-order, fix-interactive-ui-reorder): on every `message_end` for `role:"assistant"`, the pure helper `reorderToolCardsForAssistantMessage(messages, content)` reorders the trailing rows so that text/thinking/toolCall blocks land in the same order as the model's `content[]` array. **Turn-boundary anchored window**: walks `messages[]` backwards from the tail collecting every row whose role is NOT a hard turn boundary (`TURN_BOUNDARY_ROLES = {user, turnSeparator, commandFeedback, rawEvent}`). Window = `[boundaryIdx + 1 ..]` — prior-turn rows separated by a hard boundary cannot leak in. **Pairing rule** (fix-interactive-ui-reorder): for each `toolCall` block, the helper claims the matching `toolResult` row AND any `interactiveUi` row whose `toolCallId` matches, emitting them as a `[toolResult, interactiveUi]` pair so `ask_user` and other PromptBus-routed tools render below their own assistant intro text. Uses `findLastUnclaimed` (most-recent first) so back-to-back assistant turns without a user response between them still claim the current message's rows correctly. **Hybrid unclaimed-row handling**: rows of "reorderable" roles (`assistant`, `toolResult`, `thinking`) that are unclaimed keep their **original suffix index** (protects prior-message rows that bled in when no boundary exists between back-to-back assistants); rows of "trailing" roles (`interactiveUi`, `bashOutput`) that are unclaimed are emitted AFTER claimed rows so free-floating ui dialogs (no `toolCallId`) sit below their tool card. Preserves React keyed reconciliation (`tool-${id}` / `ui-${id}`). Replay path inherits the fix because it routes through the same reducer. `addInteractiveRequest(state, requestId, method, params, toolCallId?)` accepts the optional 5th param so the pushed `interactiveUi` ChatMessage carries the `toolCallId`. |
|
|
325
327
|
| `src/client/hooks/usePendingPromptTimeout.ts` | 30-second safety timeout for stuck `pendingPrompt` spinners |
|
|
326
|
-
| `src/client/lib/flow-reducer.ts` | Flow state machine: all flow_* event handling |
|
|
328
|
+
| `src/client/lib/flow-reducer.ts` | **Moved to `packages/flows-plugin/src/flow-reducer.ts`** by change `extract-flows-as-plugin`. Flow state machine: all flow_* event handling. `event-reducer.ts` now imports `isFlowEvent`/`reduceFlowEvent` from `@blackbelt-technology/pi-dashboard-flows-plugin/reducer`. Symbol-name and contract unchanged. |
|
|
327
329
|
| `src/client/lib/session-grouping.ts` | Pure functions: group, sort, filter sessions by directory |
|
|
328
330
|
| `src/client/lib/truncate-path.ts` | Middle-truncation utility for filesystem paths |
|
|
331
|
+
| `src/client/lib/session-card-time.ts` | Pure helper `selectBadgeTimestamp(session)` for the session-card relative-time badge. Precedence: `status === "ended"` → `endedAt ?? lastActivityAt ?? startedAt`; otherwise `lastActivityAt ?? startedAt`. Consumed by `SessionCard.tsx` at the two badge sites; the badge `<span>` also carries a `title="Started <localized timestamp>"` tooltip so the original spawn time stays discoverable. See change: session-card-last-activity-badge. |
|
|
332
|
+
| `src/client/lib/selectViewedSessionId.ts` | Pure helper `selectViewedSessionId(match, params)` returning the `:id` segment of the `/session/:id` route or `null`. Decoupled from `wouter` so the rule is unit-testable. Used by `App.tsx` to drive `useViewDispatcher`. See change: session-card-unread-stripes. |
|
|
333
|
+
| `src/client/hooks/useViewDispatcher.ts` | React hook wired into `App.tsx` that watches the result of `selectViewedSessionId(...)` plus the `useWebSocket` connection status, and dispatches `session_view` / `session_unview` messages to the server: (1) on every change of the viewed session id (unview previous, view current); (2) on every transition INTO `connected` (re-send `session_view` for the current id so server-side state re-syncs after reconnect). Network-layer drop-on-not-OPEN is handled by `useWebSocket.send`; the reconnect re-send rule guarantees recovery. See change: session-card-unread-stripes. |
|
|
329
334
|
| `src/server/resolve-path.ts` | Safe realpath resolution (symlink handling) |
|
|
330
335
|
| `src/client/components/ElapsedBadge.tsx` | Reusable elapsed time badge: static duration or live ticking counter |
|
|
331
|
-
| `
|
|
332
|
-
| `src/client/
|
|
333
|
-
| `
|
|
334
|
-
| `src/client/
|
|
335
|
-
| `src/client/components/FlowActivityBadge.tsx` | Session card badge showing flow name and agent progress |
|
|
336
|
-
| `src/client/components/FlowLaunchDialog.tsx` | Task input dialog for launching a flow |
|
|
337
|
-
| `src/client/components/SessionFlowActions.tsx` | Session card flow launcher: searchable picker + new flow button |
|
|
336
|
+
| `packages/flows-plugin/package.json` | **NEW** workspace package introduced by change `extract-flows-as-plugin`. Carries the `pi-dashboard-plugin` manifest claiming `session-card-badge` (FlowActivityBadge, predicate `hasActiveFlow`) + `session-card-action-bar` (SessionFlowActions). Exports `./client` (component barrel) and `./reducer` (flow + architect reducer barrel). Imported by `packages/client` as a workspace dependency. Richer slot claims (`content-header-sticky`, `content-view`, `content-inline-footer`) deferred to follow-up `migrate-flows-jsx-to-slots` pending either a slot prop contract extension or component self-derivation refactor. |
|
|
337
|
+
| `packages/flows-plugin/src/client/index.tsx` | Re-export barrel for `FlowDashboard`, `FlowAgentCard`, `FlowAgentDetail`, `FlowSummary`, `FlowGraph`, `FlowArchitect`, `FlowArchitectDetail`, `FlowActivityBadge`, `FlowLaunchDialog`, `FlowTabBar`, `SessionFlowActions`, `ArchitectInputPrompt`. Also exports the `hasActiveFlow(session)` predicate consumed by the manifest's `session-card-badge` claim. Cross-package shared utilities (`MarkdownContent`, `DialogPortal`, `AgentCardShell`, `ConfirmDialog`, `SearchableSelectDialog`, `useZoomPan`, `useMobile`, `ZoomControls`, `agent-card-utils`, `BreadcrumbSlot`, `GateSlot`, `AgentMetricSlot`) are imported via deep relative paths back into `packages/client/src/` — known v1 debt; promotion to a shared client-utils package tracked as follow-up. |
|
|
338
|
+
| `packages/flows-plugin/src/reducer.ts` | Re-export barrel for `isFlowEvent`, `reduceFlowEvent`, `isArchitectEvent`, `reduceArchitectEvent`. Imported by `packages/client/src/lib/event-reducer.ts` as `@blackbelt-technology/pi-dashboard-flows-plugin/reducer`. |
|
|
339
|
+
| **Moved to flows-plugin** | `FlowDashboard.tsx` → `packages/flows-plugin/src/client/FlowDashboard.tsx` (sticky flow card grid above ChatView). `FlowAgentCard.tsx` (status/tools/tokens). `FlowAgentDetail.tsx` (full content-area). `FlowSummary.tsx` (post-completion summary). `FlowActivityBadge.tsx` (session card badge). `FlowLaunchDialog.tsx` (task input). `SessionFlowActions.tsx` (searchable picker). `FlowGraph.tsx`, `FlowArchitect.tsx`, `FlowTabBar.tsx`. All moved via `git mv` (history preserved). Shell still imports them directly via `@blackbelt-technology/pi-dashboard-flows-plugin/client` — JSX-to-slot-consumer migration deferred. See change: extract-flows-as-plugin. |
|
|
338
340
|
| `src/client/components/SearchableSelectDialog.tsx` | Shared searchable select dialog (keyboard nav, filtering, badges) |
|
|
339
341
|
| `src/shared/diff-types.ts` | Types for session file diff API (FileChangeEvent, FileDiffEntry, SessionDiffResponse) |
|
|
340
342
|
| `src/server/session-diff.ts` | Server-side event scanning + git diff extraction for session file changes |
|
|
341
343
|
| `src/client/components/FileDiffView.tsx` | Split-pane container: file tree + diff panel, content-area view |
|
|
342
344
|
| `src/client/components/DiffFileTree.tsx` | Two-level file tree with change events, timestamps, context messages |
|
|
343
|
-
| `src/client/components/DiffPanel.tsx` | Rich diff rendering via @git-diff-view/react with syntax highlighting |
|
|
345
|
+
| `src/client/components/DiffPanel.tsx` | Rich diff rendering via @git-diff-view/react with syntax highlighting. **File view** routes through `getSyntaxTheme(theme, themeName)` (resolved via `useThemeContext`) instead of the raw `oneDark` import, so the file viewer respects the active app theme AND inherits the token-background strip. See change: strip-token-backgrounds-in-code-blocks. |
|
|
346
|
+
| `packages/client/src/lib/syntax-theme.ts` | Single source of truth for prism syntax styles in the client. `getSyntaxTheme(resolved, themeName)` resolves the prism palette for the active app theme and runs every result through the pure helper `stripTokenBackgrounds(style)` which removes `background` / `backgroundColor` from every selector containing `.token` (including `.token.deleted` / `.token.inserted` diff washes inside fenced ```diff blocks). Wrapper selectors (`pre[class*="language-"]`, `code[class*="language-"]`) are left untouched so the panel background stays overridable to `var(--bg-code)`. The fallback path (unknown theme) also runs through the strip. Pinned by `packages/client/src/lib/__tests__/syntax-theme.test.ts` (13 assertions across base/dracula/nord/github/catppuccin × dark/light). See change: strip-token-backgrounds-in-code-blocks. |
|
|
344
347
|
| `src/client/hooks/useSessionDiff.ts` | Fetch hook for `/api/session-diff` endpoint |
|
|
345
348
|
| `src/client/lib/diff-tree.ts` | Directory tree builder from flat file paths |
|
|
346
349
|
| `src/server/session-api.ts` | REST wrappers for WebSocket-only session operations (prompt, abort, spawn, resume, etc.) |
|
|
@@ -358,24 +361,24 @@ make clean # Destroy all cloned VMs
|
|
|
358
361
|
| `.pi/skills/browser-visual-debug/SKILL.md` | Skill: visual debugging with a real browser (screenshots, interaction, responsive testing) via pi-agent-browser |
|
|
359
362
|
| `.pi/skills/browser-visual-debug/references/` | Dashboard recipes, responsive testing presets, agent-browser commands cheatsheet |
|
|
360
363
|
| `.pi/skills/browser-visual-debug/scripts/detect-dashboard.sh` | Auto-detect dashboard URL, mode, and Vite dev server status |
|
|
361
|
-
| `packages/electron/src/main.ts` | Electron main process: single-instance, wizard, server launch, loading page, tray. **External-link hardening (change: harden-external-link-handling, #13)**: `createMainWindow` registers `webContents.setWindowOpenHandler` + `will-navigate` BEFORE `loadURL` so `target="_blank"` / `window.open` and bare-`<a>` external navigation both route through `shell.openExternal` (system browser) instead of replacing the dashboard.
|
|
362
|
-
| `packages/electron/src/lib/link-handling.ts` | Pure
|
|
364
|
+
| `packages/electron/src/main.ts` | Electron main process: single-instance, wizard, server launch, loading page, tray. **Power-user-mode managed install** (change: fix-electron-windows-installer-and-server-bootstrap, Defect 1): the firstRun `pi.found && bridge.found` auto-skip path no longer skips `installStandalone()`. Auto-skip removes the wizard UI; the managed install (tsx + pi@0.70.0 + openspec from the offline cacache) runs anyway so the bundled server's runtime can resolve its TS loader from `~/.pi-dashboard/`. Decision logic extracted into the pure `decideStartupAction(state)` helper in `lib/power-user-install.ts` and pinned by `wizard-power-user-managed-install.test.ts`. **External-link hardening (change: harden-external-link-handling, #13)**: `createMainWindow` registers `webContents.setWindowOpenHandler` + `will-navigate` BEFORE `loadURL` so `target="_blank"` / `window.open` and bare-`<a>` external navigation both route through `shell.openExternal` (system browser) instead of replacing the dashboard. The `will-navigate` callback is **OAuth-aware** via `decideWillNavigate(serverUrl, webContents.getURL(), url)` (change: fix-oauth-blocked-by-external-link-guard) so the trap-guard fires only when leaving the dashboard — while the user is mid-login on an OAuth provider page (Google, GitHub, generic OIDC), provider-internal multi-step navigation is allowed and the eventual callback redirect lands cleanly. `setWindowOpenHandler` is unchanged. |
|
|
365
|
+
| `packages/electron/src/lib/link-handling.ts` | Pure helpers used by the Electron shell. **`isSameOriginUrl(href, serverOrigin)`** — classifies URLs as same-origin vs external (handles absolute, relative, fragment-only, and malformed inputs; see change: harden-external-link-handling, #13). **`decideWillNavigate(serverOrigin, currentUrl, targetUrl) → "allow" | "open-external" | "cancel"`** — OAuth-aware decision wrapper for the `will-navigate` callback: while the BrowserWindow shows a non-dashboard origin (mid-OAuth) it returns `"allow"` so provider login flows proceed; on the dashboard it composes `isSameOriginUrl` to decide between `"allow"` (same-origin) and `"open-external"` (external trap-guard); fail-closes to `"cancel"` on unparseable `serverOrigin` (see change: fix-oauth-blocked-by-external-link-guard). No Electron imports; 23 unit tests in `packages/electron/src/__tests__/link-handling.test.ts`. |
|
|
363
366
|
| `packages/client/src/components/MarkdownContent.tsx` | ReactMarkdown-based renderer for chat bodies, thinking blocks, flow agent detail, package READMEs, markdown previews. **External-link hardening (change: harden-external-link-handling, #13)**: exports pure `isExternalHref(href)` and overrides the `a` component so external URLs render with `target="_blank" rel="noopener noreferrer"` while fragment-only and same-origin hrefs stay in-document. |
|
|
364
367
|
| `packages/client/src/__tests__/no-bare-external-anchor.test.ts` | Repo-level lint: scans client `.tsx` files for `<a href="http(s)://...">` tags missing `target="_blank"`. Per-line opt-out via `// ban:bare-anchor-ok`. See change: harden-external-link-handling. |
|
|
365
|
-
| `packages/electron/src/lib/server-lifecycle.ts` | Health check → tsx binary spawn (inlined config/health, no shared pkg imports) |
|
|
368
|
+
| `packages/electron/src/lib/server-lifecycle.ts` | Health check → tsx binary spawn (inlined config/health, no shared pkg imports). **60-second startup deadline + cause-aware error wording** (change: fix-electron-windows-installer-and-server-bootstrap, Defect 4): exports `SERVER_READY_DEADLINE_MS = 60_000` (was inline `15_000`) and a pure helper `buildServerStartupError(...)` that renders "Server child process exited prematurely (...)" when `ready.error` mentions an exit, vs. "Server did not respond within 60 seconds (...)" when the deadline elapsed. The pre-fix message conflated both cases and was misleading when the child died in <1s. Both `launchViaCli` and `launchServer` use the same constant + helper. |
|
|
366
369
|
| `packages/server/src/extension-register.ts` | Auto-registers bundled bridge extension in pi's global settings on startup |
|
|
367
370
|
| `packages/electron/src/lib/doctor.ts` | Doctor diagnostic: checks all binaries, versions, server status, offers setup |
|
|
368
371
|
| `packages/electron/src/lib/app-menu.ts` | App menu with About dialog and Doctor on all platforms |
|
|
369
372
|
| `packages/electron/src/lib/tray.ts` | System tray with platform-specific icons (template on macOS, ico/png on Win/Linux) |
|
|
370
373
|
| `packages/electron/src/lib/dependency-installer.ts` | Async npm install of pi, openspec, tsx into ~/.pi-dashboard/ using bundled Node |
|
|
371
|
-
| `packages/electron/src/lib/dependency-detector.ts` | Detects pi, openspec, Node.js on system PATH and managed install. **AppImage self-recursion guard** (change: fix-electron-appimage-cli-self-detection): `detectPiDashboardCli` rejects any candidate that matches `isAppImageSelfHit(path)` (in addition to the existing `_npx` filter) so power-user mode falls through to the standalone tsx + `cli.ts` path when the only `pi-dashboard` on PATH is the AppImage's own launcher (`packagerConfig.executableName: "pi-dashboard"` collides by design). `detectPi` and `detectSystemNode` apply the same guard symmetrically on the registry-resolved path — belt-and-braces beyond the `whereStrategy` filter. |
|
|
374
|
+
| `packages/electron/src/lib/dependency-detector.ts` | Detects pi, openspec, Node.js on system PATH and managed install. **AppImage self-recursion guard** (change: fix-electron-appimage-cli-self-detection): `detectPiDashboardCli` rejects any candidate that matches `isAppImageSelfHit(path)` (in addition to the existing `_npx` filter) so power-user mode falls through to the standalone tsx + `cli.ts` path when the only `pi-dashboard` on PATH is the AppImage's own launcher (`packagerConfig.executableName: "pi-dashboard"` collides by design). `detectPi` and `detectSystemNode` apply the same guard symmetrically on the registry-resolved path — belt-and-braces beyond the `whereStrategy` filter. **Windows extension filter** (change: fix-electron-windows-installer-and-server-bootstrap, Defect 3): exports a pure helper `pickSpawnableShim(rawWhereOutput, platform)` that, on `win32`, prefers candidates ending in `.cmd`/`.exe`/`.bat`/`.ps1` over an extensionless POSIX shim from npm-global. `spawn()` without `shell:true` cannot invoke an extensionless shim on Windows, so the pre-fix `lines[0]` pick produced `ENOENT`. POSIX behaviour (single-line `which`) unchanged. Locked by `dependency-detector-windows-extensions.test.ts`. |
|
|
372
375
|
| `packages/electron/src/lib/bundled-node.ts` | Resolves bundled Node.js/npm paths in Electron resources |
|
|
373
376
|
| `packages/electron/src/lib/wizard-window.ts` | First-run setup wizard window with preload bridge |
|
|
374
|
-
| `packages/electron/forge.config.ts` | Electron Forge config: DMG, DEB, AppImage, NSIS makers, icon, extraResources |
|
|
377
|
+
| `packages/electron/forge.config.ts` | Electron Forge config: DMG, DEB, AppImage, NSIS makers, icon, extraResources. **NSIS naming overrides** (change: fix-electron-windows-installer-and-server-bootstrap): the NSIS maker's `getAppBuilderConfig` callback explicitly pins `productName`, `appId`, `nsis.artifactName`, `nsis.shortcutName`, `nsis.uninstallDisplayName` all to `pi-dashboard`. Without this override, electron-builder's NSIS install-dir fallback chain reads npm `name` slash-stripped and produces `@blackbelt-technologypi-dashboard-electron`. Locked by `forge-config-naming.test.ts`. |
|
|
375
378
|
| `packages/electron/scripts/build-installer.sh` | Build script: native + Docker cross-platform (--linux, --windows, --all) |
|
|
376
379
|
| `packages/electron/scripts/docker-make.sh` | Docker entrypoint: platform-aware native module handling, ZIP for Windows |
|
|
377
380
|
| `packages/electron/scripts/Dockerfile.build` | Docker image for cross-platform builds (node:22-bookworm-slim + build tools) |
|
|
378
|
-
| `packages/electron/scripts/bundle-server.
|
|
381
|
+
| `packages/electron/scripts/bundle-server.mjs` | Bundles dashboard server source + deps into resources/server/ (`--source-only` for cross-builds). Node-native ESM script (replaces `bundle-server.sh`) so Windows electron CI runs without MSYS/bash. Uses `fs.cpSync` / `fs.chmodSync` / recursive `readdir` instead of `cp -R`/`find`/`chmod`/`du`. Verified bit-parity with the old shell script: identical 2251-file layout, identical structure. See change: eliminate-bash-on-windows-runners. **Architectural lock**: synthetic `package.json` deliberately does NOT declare `@mariozechner/pi-coding-agent` (or any managed-dir-resident dep). The bundled tree only contains workspace deps (`fastify`, `ws`, `node-pty`, etc.) directly imported by the bundled `cli.ts`. pi/openspec/tsx live in the managed dir (`~/.pi-dashboard/`) and are installed there by `installStandalone()` from the offline cacache pinned in `offline-packages.json`. An earlier `/opsx:apply` session against `fix-electron-windows-installer-and-server-bootstrap` proposed adding a `dependencies: { "@mariozechner/pi-coding-agent": "0.70.0" }` block here — reverted as architecturally wrong (would duplicate ~10MB and create version-drift risk vs. the offline cacache). Bundle stays at ~80MB; if it ever climbs to ~160MB, that's the regression marker. See change: fix-electron-windows-installer-and-server-bootstrap (D5 reconsidered). |
|
|
379
382
|
| `packages/electron/offline-packages.json` | Pinned versions of pi-coding-agent / openspec / tsx that get bundled as an offline npm cacache per release (see change: `electron-offline-bundled-packages`) |
|
|
380
383
|
| `packages/electron/scripts/bundle-offline-packages.sh` | Build-time script that runs `npm install --os=<os> --cpu=<cpu> --ignore-scripts` for the pinned versions, tars the resulting `_cacache/` (pax format — ustar is too narrow), writes `manifest.json` with SHA-256, and enforces a 100 MB hard budget. Opt-in via `BUNDLE_OFFLINE_PACKAGES=1`. |
|
|
381
384
|
| `packages/electron/resources/offline-packages/manifest.json` | Offline-cache manifest (`{bundledAt, targetPlatform, tarball, tarballBytes, sha256, packages}`). Consumed at runtime by `dependency-installer.ts` via `resolveOfflinePackages()`. |
|
|
@@ -391,8 +394,9 @@ make clean # Destroy all cloned VMs
|
|
|
391
394
|
| `packages/electron/scripts/test-electron-install.sh` | Full e2e Docker test: install, wizard, server launch, health check |
|
|
392
395
|
| `packages/electron/scripts/test-electron-install-inner.sh` | Inner test script run inside Docker container |
|
|
393
396
|
| `packages/electron/resources/icon.png` | Master 1024×1024 app icon (π on dark navy) |
|
|
394
|
-
| `.github/workflows/publish.yml` | CI: builds DMG (macOS), DEB+AppImage (Linux), NSIS+ZIP+portable (Windows) on native runners; publishes npm + GitHub Release. **Two triggers**: (a) push of any `v*` tag (the original path — release-cut skill / hand tag), (b) `workflow_dispatch` from the GitHub Actions UI with a single required `version` input (e.g. `"0.4.1"`). The `prepare` job branches on `github.event_name`: tag-push extracts the version from `GITHUB_REF_NAME`; dispatch validates the input as semver, checks tag uniqueness on origin, bumps every workspace `package.json` via `npm version -ws`, syncs cross-ref specifiers via `scripts/sync-versions.js`, promotes `## [Unreleased]` to a dated `## [<version>]` section in `CHANGELOG.md`, commits + tags + pushes the branch. The publish, electron, and github-release jobs all `needs: prepare` and check out `ref: ${{ needs.prepare.outputs.tag }}` so both trigger paths publish the same tree. **Idempotent ordered npm publish (commit b9fcea9)**: the publish step replaced the bulk `npm publish --workspaces --include-workspace-root` call with a per-package loop that (a) **skips** already-published versions via `npm view <pkg>@<ver>` (so a re-run after partial-publish failure resumes cleanly instead of aborting on "cannot publish over previously published"), (b) publishes the **4 stable sub-packages first** (`pi-dashboard-shared` → `extension` → `server` → `web`), then the brand-new `dashboard-plugin-runtime`, then the **root metapackage last** so the registry already serves matching sub-package versions before the root tarball lands and resolves dependents like `@blackbelt-technology/pi-dashboard-extension@^X.Y.Z`. v0.4.0 and v0.4.1 shipped broken because the bulk call aborted on the first error and only the root tarball landed — `npm install @blackbelt-technology/pi-agent-dashboard@0.4.1` returned ETARGET on the sub-deps. Single-failure isolation: any non-skip failure marks the step failed via a `FAIL=1` accumulator but lets the loop finish so logs show every package's outcome. Also (commit b9fcea9): `packages/server/package.json#dependencies` now declares `@blackbelt-technology/dashboard-plugin-runtime: ^<ver>` — previously imported via workspace symlinks but missing from the published tarball, so a clean `npm install` of just the server crashed with `MODULE_NOT_FOUND` on first start. Also (commit 2728c31): every workspace `package.json` (`shared`, `extension`, `server`, `client`, `dashboard-plugin-runtime`, plus `electron`) now declares a `repository` field — required for npm provenance attestation validation when publishing with `--provenance` from GitHub Actions OIDC. **Electron-publish dependency-graph contract** (change: publish-fix-macos): the `electron` matrix job declares `needs: [prepare, publish]` and `strategy.fail-fast: false`. The `needs: publish` gate closes the ETARGET race that broke release run #34 — `bundle-server.
|
|
397
|
+
| `.github/workflows/publish.yml` | CI: builds DMG (macOS), DEB+AppImage (Linux), NSIS+ZIP+portable (Windows) on native runners; publishes npm + GitHub Release. **Two triggers**: (a) push of any `v*` tag (the original path — release-cut skill / hand tag), (b) `workflow_dispatch` from the GitHub Actions UI with a single required `version` input (e.g. `"0.4.1"`). The `prepare` job branches on `github.event_name`: tag-push extracts the version from `GITHUB_REF_NAME`; dispatch validates the input as semver, checks tag uniqueness on origin, bumps every workspace `package.json` via `npm version -ws`, syncs cross-ref specifiers via `scripts/sync-versions.js`, promotes `## [Unreleased]` to a dated `## [<version>]` section in `CHANGELOG.md`, commits + tags + pushes the branch. The publish, electron, and github-release jobs all `needs: prepare` and check out `ref: ${{ needs.prepare.outputs.tag }}` so both trigger paths publish the same tree. **Idempotent ordered npm publish (commit b9fcea9)**: the publish step replaced the bulk `npm publish --workspaces --include-workspace-root` call with a per-package loop that (a) **skips** already-published versions via `npm view <pkg>@<ver>` (so a re-run after partial-publish failure resumes cleanly instead of aborting on "cannot publish over previously published"), (b) publishes the **4 stable sub-packages first** (`pi-dashboard-shared` → `extension` → `server` → `web`), then the brand-new `dashboard-plugin-runtime`, then the **root metapackage last** so the registry already serves matching sub-package versions before the root tarball lands and resolves dependents like `@blackbelt-technology/pi-dashboard-extension@^X.Y.Z`. v0.4.0 and v0.4.1 shipped broken because the bulk call aborted on the first error and only the root tarball landed — `npm install @blackbelt-technology/pi-agent-dashboard@0.4.1` returned ETARGET on the sub-deps. Single-failure isolation: any non-skip failure marks the step failed via a `FAIL=1` accumulator but lets the loop finish so logs show every package's outcome. Also (commit b9fcea9): `packages/server/package.json#dependencies` now declares `@blackbelt-technology/dashboard-plugin-runtime: ^<ver>` — previously imported via workspace symlinks but missing from the published tarball, so a clean `npm install` of just the server crashed with `MODULE_NOT_FOUND` on first start. Also (commit 2728c31): every workspace `package.json` (`shared`, `extension`, `server`, `client`, `dashboard-plugin-runtime`, plus `electron`) now declares a `repository` field — required for npm provenance attestation validation when publishing with `--provenance` from GitHub Actions OIDC. **Electron-publish dependency-graph contract** (change: publish-fix-macos): the `electron` matrix job declares `needs: [prepare, publish]` and `strategy.fail-fast: false`. The `needs: publish` gate closes the ETARGET race that broke release run #34 — `bundle-server.mjs` runs `npm install --omit=dev` against the live npm registry and resolves `@blackbelt-technology/dashboard-plugin-runtime@^<ver>` (added in commit b9fcea9 to fix `MODULE_NOT_FOUND` on clean server installs), so it must run AFTER publish has uploaded the just-bumped sub-packages. `fail-fast: false` keeps a single-OS failure from cancelling the other four matrix variants — release engineers see the full diagnostic per OS instead of one error and four cancellations. Locked by `packages/shared/src/__tests__/publish-workflow-contract.test.ts`. **No-bash-on-Windows invariant** (change: eliminate-bash-on-windows-runners): no step in `publish.yml` or `ci.yml` combines `shell: bash` with a runtime configuration that can run on a `windows-latest` runner. Cross-OS build orchestration lives in `.mjs` scripts invoked by `node`; POSIX-only steps use `shell: bash` gated by `if: matrix.platform != 'win32'`; Windows-only steps use `shell: pwsh`. The bundle scripts (`bundle-server.mjs`, `bundle-offline-packages.mjs`, `bundle-recommended-extensions.mjs`) are Node-native, eliminating the bash↔Node bridge that produced the `MODULE_NOT_FOUND` regression on Windows runners. Locked by `packages/shared/src/__tests__/no-bash-on-windows.test.ts`. |
|
|
395
398
|
| `packages/shared/src/__tests__/publish-workflow-contract.test.ts` | Repo-level lint: parses `.github/workflows/publish.yml`, asserts the electron job's `needs:` array contains both `prepare` and `publish` AND `strategy.fail-fast` is the literal `false`. Failure messages cite change `publish-fix-macos` so the contributor knows where to look. Mirrors `no-direct-process-kill.test.ts` and `no-raw-node-import.test.ts`. See change: publish-fix-macos. |
|
|
399
|
+
| `packages/shared/src/__tests__/no-bash-on-windows.test.ts` | Repo-level lint: parses every workflow YAML, computes per-step Windows reachability from `electron` matrix × each step's `if:` filter (small grammar: `matrix.platform == 'X'`, `matrix.platform != 'X'`, `&&`, `||`, `!(...)` , parens), and fails when any `shell: bash` step is reachable on a Windows runner. Failure messages cite change `eliminate-bash-on-windows-runners` plus the offending `file:line` + step name. Unrecognised `if:` expressions fail closed (force the contributor to write a recognisable form or extend the evaluator). See change: eliminate-bash-on-windows-runners. |
|
|
396
400
|
|
|
397
401
|
## Build & Restart Workflow
|
|
398
402
|
|
|
@@ -449,6 +453,7 @@ In `--dev` mode, the server proxies to Vite for HMR. If Vite is not running, it
|
|
|
449
453
|
- `POST /api/restart` waits for the old server to exit, starts a new one, and verifies health
|
|
450
454
|
- `POST /api/restart` with body `{"dev": true}` or `{"dev": false}` switches modes
|
|
451
455
|
- `pi-dashboard stop` kills stale processes holding the ports (via `lsof`), not just the PID file
|
|
456
|
+
- **Single restart path** (change: fix-restart-bridge-auto-start-race): `/api/restart` is the single source of truth. `pi-dashboard restart` (CLI) probes `isDashboardRunning(port)` and **delegates to `/api/restart`** when the dashboard is up; only when no dashboard is running does it fall back to local `cmdStop` + `cmdStart`. The `restart-helper.ts` orchestrator runs detached, kills the previous PID explicitly (SIGTERM → SIGKILL), then spawns the replacement. Before exit, the server broadcasts `server_restarting { reason, quiesceMs }` to every connected pi bridge so bridges suppress their auto-start spawn step for the quiesce window (5 s for restart, 60 s for shutdown) and don't race the orchestrator. Discovery + reconnection still run during the window so bridges pick up the new server as soon as it advertises.
|
|
452
457
|
|
|
453
458
|
## OpenSpec Conventions
|
|
454
459
|
|
package/README.md
CHANGED
|
@@ -159,6 +159,7 @@ CLI flags → environment variables → config file → built-in defaults.
|
|
|
159
159
|
| — | — | `autoShutdown` | `false` | Server shuts down when idle |
|
|
160
160
|
| — | — | `shutdownIdleSeconds` | `300` | Seconds idle before auto-shutdown |
|
|
161
161
|
| — | — | `spawnStrategy` | `"headless"` | Session spawn mode: `"headless"` or `"tmux"` |
|
|
162
|
+
| — | — | `reattachPlacement` | `"always"` | After a dashboard restart, where re-registering bridges land in folder lists. `"always"` (top), `"streaming-only"` (only mid-completion), `"preserve"` (legacy: keep prior drag order) |
|
|
162
163
|
| — | — | `devBuildOnReload` | `false` | Rebuild client + restart server on `/reload` |
|
|
163
164
|
|
|
164
165
|
The bridge also honours `PI_DASHBOARD_URL=ws://host:port` to point at a remote server instead of localhost.
|
package/docs/architecture.md
CHANGED
|
@@ -101,6 +101,20 @@ TypeScript type definitions shared across all components:
|
|
|
101
101
|
4. Server broadcasts to all subscribed browsers via `event` message
|
|
102
102
|
5. Browser's event reducer processes event, React renders update
|
|
103
103
|
|
|
104
|
+
**Last-activity stamping** (change: session-card-last-activity-badge): inside step 3, before any other event-derived updates, the server checks `isActivityEvent(eventType)` against a curated allowlist (`prompt_send`, `message_*`, `turn_end`, `tool_execution_*`, `agent_*`, `bash_output`, `flow_*`, `architect_*`). On a match — and only when the session is NOT in replay — it stamps `session.lastActivityAt = Date.now()`. The in-memory write is unconditional; the `session_updated` broadcast is throttled to **at most one per 30 s per session** via `lastActivityBroadcastAt: Map<sessionId, ms>`. The map entry is dropped on `session_unregister` so a fast re-register cannot lose its first broadcast. Heartbeat / metrics / UI-state events (`process_metrics`, `git_info_update`, `model_select`, `ui_data_list`, `ext_ui_decorator`, …) are excluded so an idle pi process emitting periodic metrics does not keep the badge artificially fresh. At server boot, `session-scanner.ts` cold-start-seeds `lastActivityAt` from the `events.jsonl` file mtime so existing idle sessions retain a meaningful relative-time label across restarts. The client's `selectBadgeTimestamp(session)` (in `packages/client/src/lib/session-card-time.ts`) renders `endedAt ?? lastActivityAt ?? startedAt` for ended sessions and `lastActivityAt ?? startedAt` for active ones.
|
|
105
|
+
|
|
106
|
+
**Unread state machine** (change: session-card-unread-stripes): every session carries a `unread: boolean` field that flips to `true` when an attention-worthy event fires while no browser has the session displayed, and clears to `false` when any browser opens the session. The visual is cyan scrolling stripes (`card-unread-pulse`, Tailwind `cyan-400`) on the session card, lower priority than the yellow streaming and purple ask_user pulses.
|
|
107
|
+
|
|
108
|
+
- **Triggers** (evaluated by the pure helper `isUnreadTrigger(eventType, before, after, payload)` in `event-status-extraction.ts`):
|
|
109
|
+
1. Session status transitions from `streaming` to `idle` or `active` — a turn finished.
|
|
110
|
+
2. Session's `currentTool` becomes `"ask_user"` — input is requested.
|
|
111
|
+
3. An `agent_end` event arrives with a truthy `payload.error` — something broke.
|
|
112
|
+
Other events (assistant `message_end`, tool execution start/end, model/git/metrics noise) deliberately do NOT trigger unread — they would be too noisy on long turns.
|
|
113
|
+
- **"Currently viewing" registry**: `viewed-session-tracker.ts` exposes `Map<sessionId, Set<WebSocket>>`. Browsers populate it via two new browser→server messages, `session_view` and `session_unview`, sent by the client hook `useViewDispatcher` (mounted in `App.tsx`). The hook watches the `/session/:id` route and the WebSocket connection status; on every transition INTO `connected` it re-sends `session_view` for the current id so server-side state re-syncs after reconnect. On WS `close`, the gateway calls `tracker.unviewAll(ws)` so disconnected browsers cannot hold sessions in the viewed state. Read state is GLOBAL across browsers (mirrors mail/Slack: opening on phone clears unread on laptop).
|
|
114
|
+
- **State transitions** in `event-wiring.ts`: right after the `extractSessionUpdates` block, the wiring snapshots `{status, currentTool}` before/after the update and calls `isUnreadTrigger`. If true AND `viewedSessionTracker.isViewedByAnyone(sessionId) === false` AND `!replayingSessions.has(sessionId)`, the wiring stamps `session.unread = true` and broadcasts `session_updated`. The browser-gateway's `session_view` arm clears the bit (`unread: false`) and broadcasts. The clear-on-already-read path is a no-op (no spurious broadcast).
|
|
115
|
+
- **Persistence**: the bit lives in `.meta.json` (`SessionMeta.unread`). `server.ts onChange` writes it on every session update; `session-scanner.ts::sessionFromMeta` restores it on cold start. The cold-start "force `status = ended`" override at `server.ts:273-279` is intentionally non-destructive on `unread` — a session that was unread when the server stopped is still unread when it starts back up, even before its bridge reattaches.
|
|
116
|
+
- **Render precedence** (`SessionCard.tsx::getCardPulseClass`): `ask_user` (purple) > `streaming || resuming` (yellow) > `unread` (cyan) > none. Streaming with `unread: true` shows yellow stripes; when streaming ends with the session still unviewed, the trigger fires, the card flips to cyan. The `card-unread-pulse` CSS class reuses the `card-working-stripes-scroll` and `card-working-opacity-pulse` keyframes verbatim — only the stripe and tint colors change to cool cyan (`rgba(34, 211, 238, 0.18)` and `rgba(34, 211, 238, 0.07)`). Cyan was selected to occupy its own corner of the dashboard palette (distant from yellow, purple, green, red). Reduced-motion users see a static cyan-tinted background, matching the working-pulse arm.
|
|
117
|
+
|
|
104
118
|
### Interactive UI Flow (PromptBus — extension dialog → browser → response)
|
|
105
119
|
1. Extension calls `ctx.ui.confirm()` / `select()` / `input()` / `editor()` / bridge-patched `multiselect()`
|
|
106
120
|
2. Bridge PromptBus intercepts via patched `ctx.ui` methods, creates a `PromptRequest` with a unique `promptId` and `pipeline` tag (e.g. `"command"`, `"architect"`)
|
|
@@ -155,8 +169,8 @@ pi-flows runs multi-agent workflows in-process. Subagent sessions use `SessionMa
|
|
|
155
169
|
1. pi-flows `EventEmitObserver` emits `flow:*` events on `pi.events` (all 10 `FlowObserver` callbacks)
|
|
156
170
|
2. Bridge extension listens to `flow:*` events and forwards as `event_forward` messages with `flow_*` event types
|
|
157
171
|
3. Server stores events, extracts flow metadata to `DashboardSession` fields (`activeFlowName`, `flowAgentsDone`, `flowAgentsTotal`, `flowStatus`)
|
|
158
|
-
4. Browser event reducer builds client-side `FlowState` (agents map, tool history, detail entries)
|
|
159
|
-
5. React renders `FlowDashboard` (sticky card grid above ChatView), `FlowAgentDetail` (replaces chat), `FlowSummary` (post-completion)
|
|
172
|
+
4. Browser event reducer builds client-side `FlowState` (agents map, tool history, detail entries) — reducer code lives in `packages/flows-plugin/src/flow-reducer.ts` (re-exported via `@blackbelt-technology/pi-dashboard-flows-plugin/reducer`); `event-reducer.ts` imports `isFlowEvent` + `reduceFlowEvent` from there.
|
|
173
|
+
5. React renders `FlowDashboard` (sticky card grid above ChatView), `FlowAgentDetail` (replaces chat), `FlowSummary` (post-completion). Component code lives in `packages/flows-plugin/src/client/` and is imported by the shell via `@blackbelt-technology/pi-dashboard-flows-plugin/client`. Slot-consumer-based mounting is tracked as the follow-up change `migrate-flows-jsx-to-slots`; the current shell imports the components directly. See change: extract-flows-as-plugin.
|
|
160
174
|
|
|
161
175
|
**Flow controls (browser → pi-flows):**
|
|
162
176
|
- Abort: browser sends `flow_control { action: "abort" }` → server → bridge → `pi.events.emit("flow:abort")` → `flowManager.abort()`
|
|
@@ -528,6 +542,22 @@ flowchart TD
|
|
|
528
542
|
|
|
529
543
|
**Why two paths?** pi-coding-agent's `ExtensionContext` (delivered to `session_start` handlers) has no `reload()` method — only `ExtensionCommandContext` (given to command handlers) does. The bridge works around this by registering `__dashboard_reload` as a command and capturing `ctx.reload` into `globalThis[RELOAD_KEY]` when a user first invokes it in pi's TUI. Headless sessions have no TUI, so the capture never happens. The server-side interception is a transparent kill-and-respawn that achieves the same user-visible outcome (fresh settings, fresh extensions, fresh skills/prompts/themes) without needing an in-process reload. Since `memorySessionManager.register` carries accumulated state when the same `sessionId` re-registers, the user sees a brief reconnect flicker but keeps their tokens, cost, context usage, and attached proposal. See change: headless-reload-via-respawn.
|
|
530
544
|
|
|
545
|
+
### Server Restart (single-orchestrator path)
|
|
546
|
+
|
|
547
|
+
The dashboard previously had three independent restart paths (CLI in-process `cmdStop`+`cmdStart`, `POST /api/restart` orchestrator, bridge auto-start), and they raced each other on every restart: when the listening server died, every connected pi bridge fired `server-auto-start.ts` to spawn a replacement, racing whatever else was trying to bring the server back up. Symptoms ranged from "`pi-dashboard restart` left the server offline" (cmdStart's `isServerRunning` check returned true after a bridge won the race, so it silently early-returned without starting anything itself) to "Electron's restart respawned the server outside the Job Object" (the orchestrator-spawned new server is `detached: true`, severing Electron's lifecycle supervision).
|
|
548
|
+
|
|
549
|
+
The `fix-restart-bridge-auto-start-race` change collapses the three paths into a single orchestrator path:
|
|
550
|
+
|
|
551
|
+
1. **CLI delegation** — `pi-dashboard restart` (`cmdRestart` in `cli.ts`) probes `isDashboardRunning(port)`. If up, POSTs `/api/restart` with `{dev}` and exits. If the dashboard is down or the HTTP call fails, falls back to local `cmdStop` + `cmdStart` (the offline-bootstrap case where there is no orchestrator to delegate to). This eliminates the in-process race, mirroring the existing `cmdUpgradePi` pattern.
|
|
552
|
+
|
|
553
|
+
2. **`server_restarting` broadcast** — before `process.exit(0)`, both `/api/restart` and `/api/shutdown` broadcast `server_restarting { reason, quiesceMs }` to every connected bridge via `piGateway.broadcast`. `quiesceMs` is 5000 for restart and 60000 for shutdown (longer because deliberate shutdown should not auto-resurrect for a minute). The broadcast is non-blocking and runs before the existing 100–200 ms `setTimeout(process.exit, ...)` deferral so the WS frame has time to flush.
|
|
554
|
+
|
|
555
|
+
3. **Bridge quiesce window** — on receipt of `server_restarting`, the bridge calls `connection.pauseAutoStart(quiesceMs)` (idempotent extend-only). `autoStartServer` consults `connection.shouldSuppressAutoStart()` and **skips only the `launchServer(...)` spawn step**; mDNS discovery + health-check probes still run, so the bridge picks up the orchestrator-spawned replacement as soon as it advertises. After the window expires, normal auto-start resumes (so a real server crash is still handled by the cold-start path).
|
|
556
|
+
|
|
557
|
+
4. **Explicit prior-daemon kill in the orchestrator** — `restart-helper.ts::buildOrchestratorScript` now reads `~/.pi/dashboard/dashboard.pid`, sends `SIGTERM` to the recorded PID, polls `kill(pid, 0)` for up to 3 s, then `SIGKILL` if still alive. The subsequent `portFree` poll deadline drops from 10 s to 5 s since step 0 already guarantees the previous server is dead.
|
|
558
|
+
|
|
559
|
+
Older bridges that don't understand `server_restarting` ignore the message and fall back to today's behaviour — the CLI fix in step 1 already eliminates the worst-case path even for them. There is no flag day; the protocol message is additive on the `ServerToExtensionMessage` discriminated union.
|
|
560
|
+
|
|
531
561
|
### Auto-Resume on Prompt
|
|
532
562
|
When a user sends a prompt to an ended session, the server automatically resumes it:
|
|
533
563
|
1. Server detects `send_prompt` for a session with `status === "ended"` and a valid `sessionFile`
|
|
@@ -543,7 +573,7 @@ When a user sends a prompt to an ended session, the server automatically resumes
|
|
|
543
573
|
### Sidebar session ordering: top-of-tier on status change
|
|
544
574
|
The sidebar splits each folder's session cards into two tiers (alive on top, ended at the bottom). Cards within each tier sort independently:
|
|
545
575
|
|
|
546
|
-
- **Alive tier** uses the persisted `sessionOrder` per cwd (drag-reorder, prepend on new spawn). On user-intent resume (Resume button, drag-to-resume, REST resume), the server calls `sessionOrderManager.moveToFront(cwd, sessionId)` so the just-resumed card surfaces at index 0 of the alive tier — even on repeated `end → resume → end → resume` cycles where the id might already be in the order list. Bridge auto-reattach
|
|
576
|
+
- **Alive tier** uses the persisted `sessionOrder` per cwd (drag-reorder, prepend on new spawn). On user-intent resume (Resume button, drag-to-resume, REST resume), the server calls `sessionOrderManager.moveToFront(cwd, sessionId)` so the just-resumed card surfaces at index 0 of the alive tier — even on repeated `end → resume → end → resume` cycles where the id might already be in the order list. **Bridge auto-reattach after a dashboard restart** is governed by the `reattachPlacement` config (`"always"` default / `"streaming-only"` / `"preserve"`): the bridge tags every `session_register` after its first call as `registerReason: "reattach"`, and `server.ts onChange` routes those into `reattach-placement.ts::applyReattachPolicy` to `moveToFront` according to policy. The `"preserve"` setting reproduces the legacy behavior of leaving order untouched. Registry intents (`pendingResumeIntents.consume()` returning `"front"` or `"keep"`) always override the reattach policy. See change: reattach-move-to-front.
|
|
547
577
|
- **Ended tier** sorts by `(endedAt ?? startedAt)` descending, computed at render time inside `SessionList.renderGroup` (no persisted `endedSessionOrder` list — pure function of session timestamps). The most-recently-ended card surfaces at the top of the ended bucket regardless of cause (✕ shutdown, natural pi exit, force-kill). Legacy sessions without a recorded `endedAt` fall back to `startedAt` so pre-migration entries keep their previous ordering.
|
|
548
578
|
|
|
549
579
|
Both halves share one mental model: "the session you just acted on appears at the top of its new tier." No protocol changes — the existing `sessions_reordered` broadcast carries the new order. See change `top-of-tier-on-status-change`.
|
|
@@ -1228,7 +1258,7 @@ This is separate from the main JSON dashboard WebSocket (`/ws`).
|
|
|
1228
1258
|
**Native binary permissions.** `node-pty`'s prebuilt `spawn-helper` (and `pty.node`) must be executable for `pty.spawn` to succeed on macOS/Linux. Three layers of defense ensure this:
|
|
1229
1259
|
|
|
1230
1260
|
1. **Postinstall** — `packages/server/scripts/fix-pty-permissions.cjs` (wired at workspace-root `postinstall`) uses `require.resolve("node-pty/package.json")` to locate the dependency wherever npm placed it and sets mode `0o755` on every `prebuilds/*/spawn-helper` and `prebuilds/*/pty.node`.
|
|
1231
|
-
2. **Electron bundle** — `packages/electron/scripts/bundle-server.
|
|
1261
|
+
2. **Electron bundle** — `packages/electron/scripts/bundle-server.mjs` runs `fs.chmodSync` on every `spawn-helper` after `npm install` and removes macOS quarantine flags (`xattr -d com.apple.quarantine`) from native binaries.
|
|
1232
1262
|
|
|
1233
1263
|
### Package management (install / remove / update / move)
|
|
1234
1264
|
|
|
@@ -1549,6 +1579,134 @@ Every OS-dependent function takes an optional trailing `platform: NodeJS.Platfor
|
|
|
1549
1579
|
|
|
1550
1580
|
See change: `platform-path-normalization`.
|
|
1551
1581
|
|
|
1582
|
+
## Cross-OS Build Orchestration
|
|
1583
|
+
|
|
1584
|
+
### Principle
|
|
1585
|
+
|
|
1586
|
+
Cross-OS build logic SHALL live in `.mjs` scripts invoked by `node`. POSIX-only steps MAY use `shell: bash` provided they are gated by an `if:` filter that excludes Windows. Windows-only steps MAY use `shell: pwsh`. **No GitHub Actions step combines `shell: bash` with a runtime configuration that can run on a Windows runner.**
|
|
1587
|
+
|
|
1588
|
+
### Why
|
|
1589
|
+
|
|
1590
|
+
Git for Windows' MSYS2 layer translates Win32 paths (`D:\a\...`) to POSIX form (`/d/a/...`) for any bash variable produced by `pwd`, `dirname`, etc. That translated string is invisible to native binaries when embedded in arguments — most notably `node.exe`, which receives the POSIX-form path as a literal `require()` target and rejects it with `MODULE_NOT_FOUND`. The translation only exists on Windows runners; the same script tested on a Linux dev machine cannot reproduce the failure. Result: a class of latent path-in-string bugs that surface only at release time and only on Windows.
|
|
1591
|
+
|
|
1592
|
+
MSYS exists for legitimate reasons (porting GCC, Autotools, git itself — software that is already POSIX-shaped and cannot be rewritten). None of those reasons apply to a Node project. Node has cross-OS primitives (`node:path`, `node:fs`, `node:child_process`) that work natively on every host, with zero translation layer and zero per-OS surprise.
|
|
1593
|
+
|
|
1594
|
+
### The four-cell failure-mode matrix
|
|
1595
|
+
|
|
1596
|
+
```
|
|
1597
|
+
HOST OS
|
|
1598
|
+
┌─────────────┬─────────────────┐
|
|
1599
|
+
│ POSIX │ Windows │
|
|
1600
|
+
───────────────┼─────────────┼─────────────────┤
|
|
1601
|
+
argv-position │ works │ works │
|
|
1602
|
+
path │ │ (MSYS converts)│
|
|
1603
|
+
───────────────┼─────────────┼─────────────────┤
|
|
1604
|
+
EMBEDDED │ works │ ❌ broken │
|
|
1605
|
+
in JS source │ │ MSYS can't │
|
|
1606
|
+
passed via │ │ see inside │
|
|
1607
|
+
node -e "..." │ │ string │
|
|
1608
|
+
───────────────┼─────────────┼─────────────────┤
|
|
1609
|
+
--import URL │ works │ ❌ broken │
|
|
1610
|
+
as raw path │ │ Node parses B: │
|
|
1611
|
+
(no file://) │ │ as URL scheme │
|
|
1612
|
+
───────────────┼─────────────┼─────────────────┤
|
|
1613
|
+
inside .mjs │ works │ works │
|
|
1614
|
+
path.resolve │ │ │
|
|
1615
|
+
───────────────┴─────────────┴─────────────────┘
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
The two broken cells map to existing repo invariants:
|
|
1619
|
+
|
|
1620
|
+
- **Embedded path in `node -e "..."`**: avoided by porting build scripts to `.mjs` (see `packages/electron/scripts/bundle-{server,offline-packages,recommended-extensions}.mjs`).
|
|
1621
|
+
- **Raw path in `--import` / `--loader`**: locked by `packages/shared/src/__tests__/no-raw-node-import.test.ts`. All real call sites go through `toFileUrl` from `platform/node-spawn.ts` or `buildJitiRegisterUrl` from `resolve-jiti.ts`.
|
|
1622
|
+
|
|
1623
|
+
### Shell allowlist
|
|
1624
|
+
|
|
1625
|
+
| Shell | When to use | Notes |
|
|
1626
|
+
|---|---|---|
|
|
1627
|
+
| (default — no `shell:` declared) | A single command that runs identically on every OS (`node X.mjs`, `npm install`, `npm version`) | Cmd on Windows, sh on POSIX. Both invoke the binary natively. |
|
|
1628
|
+
| `node` | Any cross-OS logic. Always preferred over a shell. | `node X.mjs` for orchestration, `node -e "..."` for one-line existence checks. |
|
|
1629
|
+
| `bash` | POSIX-only logic (`apt-get`, `xattr -d`). MUST be gated by `if: matrix.platform != 'win32'`. | Locked by the lint test. |
|
|
1630
|
+
| `pwsh` | Windows-only logic (`Compress-Archive`, `Invoke-WebRequest`, `Tee-Object`). Gated by `if: matrix.platform == 'win32'`. | Available on every CI runner image. |
|
|
1631
|
+
| `cmd` | Avoid. Use `pwsh` instead unless calling a `.cmd` shim. | |
|
|
1632
|
+
|
|
1633
|
+
### Lock
|
|
1634
|
+
|
|
1635
|
+
`packages/shared/src/__tests__/no-bash-on-windows.test.ts` parses every workflow YAML, computes per-step Windows reachability from each step's `if:` filter (small grammar: `matrix.platform == 'X'`, `matrix.platform != 'X'`, `&&`, `||`, `!(...)`, parens), and fails when any `shell: bash` step is reachable on a Windows runner. Failure messages cite this change name + the offending file:line + step name. Unrecognised `if:` expressions fail closed.
|
|
1636
|
+
|
|
1637
|
+
See change: `eliminate-bash-on-windows-runners`.
|
|
1638
|
+
|
|
1639
|
+
## Electron Server Lifecycle
|
|
1640
|
+
|
|
1641
|
+
### Power-user-mode managed install (Defect 1 fix)
|
|
1642
|
+
|
|
1643
|
+
The Electron app's first-launch flow has three branches:
|
|
1644
|
+
|
|
1645
|
+
```
|
|
1646
|
+
firstRun?
|
|
1647
|
+
yes
|
|
1648
|
+
|
|
|
1649
|
+
v
|
|
1650
|
+
pi.found && bridge.found?
|
|
1651
|
+
/ \
|
|
1652
|
+
yes no
|
|
1653
|
+
| |
|
|
1654
|
+
v v
|
|
1655
|
+
auto-skip-wizard- pi.found?
|
|
1656
|
+
with-install / \
|
|
1657
|
+
(D3, see below) yes no
|
|
1658
|
+
| | |
|
|
1659
|
+
v v v
|
|
1660
|
+
Write mode.json Wizard Wizard
|
|
1661
|
+
Run install bridge- full
|
|
1662
|
+
install
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
The `auto-skip-wizard-with-install` branch was the source of Defect 1 in change `fix-electron-windows-installer-and-server-bootstrap`. Pre-fix, this branch wrote `mode.json` as power-user but **skipped `installStandalone()`**, leaving `~/.pi-dashboard/node_modules/` empty. The bundled server's runtime then fell back to the user's system pi for the TS loader, which on machines with `pi-coding-agent@0.71.x` ships jiti 2.6.5 — a version that misnormalizes triple-slash file:// URLs on Windows and crashes the server child with `MODULE_NOT_FOUND`.
|
|
1666
|
+
|
|
1667
|
+
The fix:
|
|
1668
|
+
|
|
1669
|
+
```typescript
|
|
1670
|
+
// packages/electron/src/lib/power-user-install.ts (pure helpers)
|
|
1671
|
+
export function decideStartupAction(state: StartupState): StartupAction {
|
|
1672
|
+
if (!state.firstRun) return { kind: "skip-everything", reason: "not-first-run" };
|
|
1673
|
+
if (state.piFound && state.bridgeFound) {
|
|
1674
|
+
return { kind: "auto-skip-wizard-with-install", reason: "power-user" };
|
|
1675
|
+
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
1676
|
+
// skip the WIZARD UI — still RUN install
|
|
1677
|
+
}
|
|
1678
|
+
if (state.piFound) return { kind: "wizard", step: "bridge-install" };
|
|
1679
|
+
return { kind: "wizard", step: "full" };
|
|
1680
|
+
}
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
The install is idempotent: `runPowerUserManagedInstall()` short-circuits when every required package's `package.json` is present and parses (`isManagedDirPopulated()`). On subsequent launches the install is a no-op.
|
|
1684
|
+
|
|
1685
|
+
The install runs async with status forwarded to the splash window's `updateSplashStatus("Setting up dependencies…")`. After the install resolves, the server-launch step takes over and the splash transitions to `"Launching dashboard server…"`.
|
|
1686
|
+
|
|
1687
|
+
### Server-startup deadline + cause-aware error wording (Defect 4 fix)
|
|
1688
|
+
|
|
1689
|
+
Both `launchViaCli` and `launchServer` in `server-lifecycle.ts` use `SERVER_READY_DEADLINE_MS = 60_000` (was inline `15_000` pre-fix). The longer deadline gives the install + cold-start headroom on first launch.
|
|
1690
|
+
|
|
1691
|
+
When `waitForReady` returns unsuccessful, the error message is built by the pure helper `buildServerStartupError(...)` which renders one of two cause-aware messages:
|
|
1692
|
+
|
|
1693
|
+
| Condition | Header text | Hint |
|
|
1694
|
+
|---|---|---|
|
|
1695
|
+
| `readyError` contains "exit" | `Server child process exited prematurely (...)` | `This usually means a missing dependency or wrong TypeScript loader.` |
|
|
1696
|
+
| Otherwise (deadline elapsed) | `Server did not respond within 60 seconds (...)` | `The server is likely still starting; try the Retry button.` |
|
|
1697
|
+
|
|
1698
|
+
Pre-fix, both cases shared the misleading wording "Server failed to start within 15 seconds (child exited with code 1)" — implying a timeout when the child died in <1s.
|
|
1699
|
+
|
|
1700
|
+
### The runtime jiti version contract (Defect 2 defense)
|
|
1701
|
+
|
|
1702
|
+
`shouldUrlWrapEntry()` in `packages/shared/src/platform/node-spawn.ts` decides whether the entry-script position in `node --import <loader> <entry>` argv needs `file://` URL wrapping. The Windows-non-tsx arm wraps with `file://` to sidestep Node's drive-letter URL-scheme parsing (`B:`, `A:` are otherwise treated as URL schemes). This rule **assumes** the jiti loader is from `pi-coding-agent@0.70.x` (jiti 2.x), which correctly handles `file:///` URL entries on Windows. Newer jiti versions (2.6.5 in pi 0.71.x) misnormalize triple-slash URLs.
|
|
1703
|
+
|
|
1704
|
+
The contract holds because Defect 1's fix populates `~/.pi-dashboard/` with `pi-coding-agent` at the offline-cacache-pinned version. The runtime `resolveJitiFromPi()` chain is `managed → system`; once managed is populated with the pinned version, system pi (which may be a newer 0.71.x) is never reached.
|
|
1705
|
+
|
|
1706
|
+
The contract is documented in the function's header comment (`!! JITI VERSION CONTRACT !!` block) and regression-pinned by `packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts`, which asserts `offline-packages.json` keeps `pi-coding-agent` in the `0.70.x` range. Bumping the pin past 0.70.x fires the test and forces a re-validation.
|
|
1707
|
+
|
|
1708
|
+
See change: `fix-electron-windows-installer-and-server-bootstrap`.
|
|
1709
|
+
|
|
1552
1710
|
## Chat Input State (drafts & history recall)
|
|
1553
1711
|
|
|
1554
1712
|
### Per-session draft persistence
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-agent-dashboard",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5-rc.1",
|
|
4
4
|
"description": "Web dashboard for monitoring and interacting with pi agent sessions",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"screenshots": "npm --prefix site run screenshots"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@blackbelt-technology/pi-dashboard-extension": "^0.4.
|
|
70
|
-
"@blackbelt-technology/pi-dashboard-server": "^0.4.
|
|
71
|
-
"@blackbelt-technology/pi-dashboard-web": "^0.4.
|
|
69
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.4.5-rc.1",
|
|
70
|
+
"@blackbelt-technology/pi-dashboard-server": "^0.4.5-rc.1",
|
|
71
|
+
"@blackbelt-technology/pi-dashboard-web": "^0.4.5-rc.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"jsdom": "^29.0.2",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-extension",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5-rc.1",
|
|
4
4
|
"description": "Pi bridge extension for pi-dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
".pi/skills/pi-dashboard/"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.4.
|
|
27
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.4.5-rc.1",
|
|
28
28
|
"ws": "^8.18.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|