@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +114 -9
- package/README.md +218 -97
- package/docs/architecture.md +107 -7
- package/package.json +9 -4
- package/packages/extension/package.json +1 -1
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +38 -4
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/server/package.json +2 -1
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
- package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tunnel.test.ts +91 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/browse.ts +100 -6
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/editor-manager.ts +20 -1
- package/packages/server/src/editor-pid-registry.ts +198 -0
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/headless-pid-registry.ts +9 -0
- package/packages/server/src/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +31 -0
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +166 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/process-manager.ts +1 -1
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +83 -1
- package/packages/server/src/routes/pi-core-routes.ts +117 -0
- package/packages/server/src/routes/provider-auth-routes.ts +4 -2
- package/packages/server/src/routes/provider-routes.ts +12 -2
- package/packages/server/src/routes/recommended-routes.ts +227 -0
- package/packages/server/src/routes/system-routes.ts +10 -1
- package/packages/server/src/server.ts +151 -15
- package/packages/server/src/terminal-manager.ts +4 -0
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +132 -8
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +3 -3
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/browser-protocol.ts +23 -1
- package/packages/shared/src/openspec-poller.ts +8 -3
- package/packages/shared/src/recommended-extensions.ts +180 -0
- package/packages/shared/src/rest-api.ts +71 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -0
- package/packages/shared/src/types.ts +7 -0
package/docs/architecture.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# PI Dashboard Architecture
|
|
2
2
|
|
|
3
|
+
> **Adjacent artifact:** the public marketing site lives at `/site` and is
|
|
4
|
+
> product-adjacent, not part of the dashboard runtime. It has its own Astro
|
|
5
|
+
> build, its own Playwright screenshot pipeline, and its own GitHub Pages
|
|
6
|
+
> deploy workflow (`.github/workflows/deploy-site.yml`). See
|
|
7
|
+
> `/site/README.md` for details.
|
|
8
|
+
|
|
9
|
+
|
|
3
10
|
## Overview
|
|
4
11
|
|
|
5
12
|
The PI Dashboard is a web-based dashboard for monitoring and interacting with pi agent sessions. It consists of three components:
|
|
@@ -75,6 +82,7 @@ A React-based responsive web UI that:
|
|
|
75
82
|
- Provides command autocomplete with `/` prefix
|
|
76
83
|
- Supports bidirectional interaction (send prompts, run commands)
|
|
77
84
|
- Works on mobile with responsive layout and swipe gestures
|
|
85
|
+
- Shows an onboarding `LandingPage` whenever the main pane is empty, narrating the three steps needed to go from install → first running session (Setup credentials → Add folder → Start session). Each step is a card in **pending**, **done**, or **locked** state, derived purely from client state: `useProvidersReady()` (from `GET /api/providers`), `pinnedDirectories.length`, and `sessions.size`. Satisfied steps collapse to single-line ✔ rows, so returning users see a compact status strip rather than a full onboarding wall. The `PinDirectoryDialog` used by Step ② is mounted once at the app root in `App.tsx` and shared with the sidebar "Add folder" button via a single `onOpenPinDialog` callback.
|
|
78
86
|
|
|
79
87
|
### 4. Shared Types (`src/shared/`)
|
|
80
88
|
TypeScript type definitions shared across all components:
|
|
@@ -226,10 +234,45 @@ When a user sends a prompt to an ended session, the server automatically resumes
|
|
|
226
234
|
3. Changes are broadcast to all connected browsers via `openspec_update { cwd, data }`
|
|
227
235
|
4. Browsers can request immediate refresh via `openspec_refresh { cwd }`
|
|
228
236
|
5. New directories (pinned or from new sessions) trigger immediate discovery + polling
|
|
237
|
+
6. Each `OpenSpecChange` carries an optional `isComplete?: boolean` field forwarded straight through from `openspec status --change <name> --json`. It indicates artifact-authoring completeness only — orthogonal to the task tally — and never feeds `deriveChangeState`. The dashboard uses it solely to gate the **Archive anyway** escape hatch (see “OpenSpec session card”).
|
|
238
|
+
|
|
239
|
+
### OpenSpec session card UI
|
|
240
|
+
|
|
241
|
+
The attached-change row on every session card has four affordances driven by the polled `OpenSpecChange`:
|
|
242
|
+
|
|
243
|
+
- **State pill** — `StatePill.tsx` renders `deriveChangeState(change)` as a small color-coded pill (`PLANNING`=zinc, `READY`=blue, `IMPLEMENTING`=amber, `COMPLETE`=green) next to the `📋 <name>` badge. Hidden when the attached change isn't present in OpenSpec data (e.g. archived under another name).
|
|
244
|
+
- **Tasks popover** — a `Tasks N/M` action button appears whenever the change has at least one parseable task. Clicking opens `TasksPopover.tsx`, a portal-rendered popover that lists every `- [ ] / - [x]` line in `tasks.md`, grouped by `## ` heading, with native checkboxes. Toggling a checkbox issues an optimistic `POST /api/openspec/tasks/toggle`; HTTP 409 (the file changed under us) refetches and surfaces a “File changed — please try again” banner. After every successful toggle the server re-polls openspec for that cwd and broadcasts the standard `openspec_update`, so card counts (`30/33` → `31/33`) refresh without a manual reload.
|
|
245
|
+
- **Archive anyway** — when `state === IMPLEMENTING && change.isComplete === true && allArtifactsDone`, an overflow `⋯` button appears on the action row. The single menu item opens a `ConfirmDialog` reading `"<unchecked> of <total> tasks are unchecked. Archive anyway?"`. Confirming dispatches `/opsx:archive <name>` through the normal `onSendPrompt` path. The default Apply button is unaffected; this is purely an escape hatch for changes whose remaining tasks are manual-verification items the user owns.
|
|
246
|
+
- **Bulk Archive relocation** — the Bulk Archive button now appears **only on unattached sessions** that have at least one folder change with `status === "complete"`. It is removed from the attached-session action row to free up space; the folder-level Bulk Archive in `FolderOpenSpecSection` is unchanged.
|
|
247
|
+
|
|
248
|
+
**Server endpoints (localhost-guarded, registered alongside the existing openspec routes in `packages/server/src/routes/openspec-routes.ts`):**
|
|
249
|
+
|
|
250
|
+
- `GET /api/openspec/tasks?cwd=<abs>&change=<name>` — parses `<cwd>/openspec/changes/<name>/tasks.md` via `parseTasksMarkdown` (top-level `- [ ] <id> <text>` / `- [x] <id> <text>` only; everything else is ignored). Returns `{ success: true, data: { tasks: OpenSpecTask[], groups: string[] } }`. 404 when the file is missing, 403 when the network guard denies.
|
|
251
|
+
- `POST /api/openspec/tasks/toggle` — body `{ cwd, change, id, done, line }`. Reads the file, validates that `line` still contains the requested `id` and the *opposite* state (optimistic-concurrency check), rewrites only that one line's `[ ]`/`[x]` marker, and atomic-writes via `tmp + rename` so other lines are preserved byte-for-byte. Maps typed errors to HTTP: `NotFoundError` → 404, `LineMismatchError` → 409, `NotACheckboxError` → 400. On success, fires a fire-and-forget `directoryService.refreshOpenSpec(cwd)` followed by an `openspec_update` broadcast.
|
|
229
252
|
|
|
230
253
|
### File Read API
|
|
231
254
|
The server exposes `GET /api/file?cwd=...&path=...` for reading files or listing directories from session working directories. Guards: localhost-only, cwd must match a known session, resolved path must stay inside cwd. Returns `{ type: "file", content }` or `{ type: "directory", entries }`.
|
|
232
255
|
|
|
256
|
+
### Filesystem Browser (PathPicker)
|
|
257
|
+
|
|
258
|
+
The dashboard's reusable directory chooser (`PathPicker`) is backed by two localhost-only endpoints:
|
|
259
|
+
|
|
260
|
+
- `GET /api/browse?path=<dir>&q=<query>` — lists subdirectories of `<dir>` (or `$HOME` when omitted), with `.git` / `.pi` detection. When `q` is non-empty, entries are case-insensitive substring-filtered and ranked:
|
|
261
|
+
- **Tier 0** exact match → **Tier 1** prefix → **Tier 2** word-boundary substring (after `-`, `_`, `.`, space, `/`) → **Tier 3** plain substring.
|
|
262
|
+
- Alphabetical within each tier. The 200-entry cap is applied **after** filter+rank so best matches always survive truncation.
|
|
263
|
+
- `POST /api/browse/mkdir` body `{ parent, name }` — creates a new directory non-recursively (`fs.mkdir` without `recursive: true`). Name validation rejects `/`, `\`, `\0`, `.`, `..`, empty, and leading/trailing whitespace. Errors map to 400 (`invalid name`, `parent is not a directory`), 404 (`parent not found`), 409 (`already exists`).
|
|
264
|
+
|
|
265
|
+
Client-side, `PathPicker` debounces the `q` request at 150ms and cancels in-flight requests via `AbortController`. Enter/Select follow a strict state machine instead of confirming arbitrary input:
|
|
266
|
+
|
|
267
|
+
1. Exact case-insensitive match against a visible entry → `onSelect(<entry.path>)` + close.
|
|
268
|
+
2. Input ends with `/` and its parsed parent equals the fetched directory → `onSelect(inputValue)` + close.
|
|
269
|
+
3. Exactly one filtered candidate → complete to `<path>/` (do not close).
|
|
270
|
+
4. Otherwise → no-op with a 300ms red-border flash.
|
|
271
|
+
|
|
272
|
+
If a debounced query is still pending when Enter fires, the client flushes it synchronously before evaluating the rules so the freshest server result is considered.
|
|
273
|
+
|
|
274
|
+
New folders can be created from two entry points — a footer **+ New folder** button (inline name entry), or an inline **+ Create "<name>" here** row shown when the typed partial has no exact match. The create-here row is suppressed if the parsed parent differs from the last-successfully-fetched directory (prevents creating inside a stale parent after a mid-path typo). On success the picker refetches and descends into the new directory.
|
|
275
|
+
|
|
233
276
|
### Pi Resources Browser
|
|
234
277
|
|
|
235
278
|
The dashboard can display pi extensions, skills, and prompts installed for each workspace. The server-side scanner (`pi-resource-scanner.ts`) discovers resources from three sources:
|
|
@@ -255,6 +298,16 @@ Metadata is parsed from SKILL.md YAML frontmatter (`name`, `description`), promp
|
|
|
255
298
|
|
|
256
299
|
Package operations use pi's `DefaultPackageManager` API on the server, serialized (one at a time, 409 on concurrent). Progress events are forwarded to browsers via `package_progress` WebSocket messages. After any successful operation, the server sends `/reload` to all connected pi sessions.
|
|
257
300
|
|
|
301
|
+
**Pi Core Version Check (separate from extension management):**
|
|
302
|
+
- `GET /api/pi-core/versions[?refresh=true]` — returns `PiCoreStatus` with all discovered pi ecosystem CLI packages (pi itself, pi-dashboard, pi-model-proxy, bare `pi-*` and scoped `@x/pi-*`), their installed version, latest npm-registry version, `updateAvailable` flag, and `installSource` (`"global"` via `npm list -g --depth=0 --json` vs `"managed"` in `~/.pi-dashboard/node_modules/`). Cached 5 min.
|
|
303
|
+
- `POST /api/pi-core/update` with `{ packages?: string[] }` — updates the listed packages, or all packages with `updateAvailable` when omitted. Runs `npm update -g <pkg>` (global) or `npm update <pkg>` in `~/.pi-dashboard/` (managed). Shares the `PackageManagerWrapper.runExclusive()` busy-lock with extension operations — returns 409 on contention.
|
|
304
|
+
|
|
305
|
+
Why a separate system? Pi's `DefaultPackageManager` only manages packages listed in `settings.json packages[]` (extensions/skills/prompts/themes). The pi CLI binary itself and the dashboard server package are installed directly via `npm -g` (or into `~/.pi-dashboard/` in the Electron case) and are invisible to pi's manager. `PiCoreChecker` + `PiCoreUpdater` (`pi-core-checker.ts` + `pi-core-updater.ts`) fill that gap.
|
|
306
|
+
|
|
307
|
+
Progress for core updates is delivered via typed `pi_core_update_progress` / `pi_core_update_complete` browser-protocol messages (not the `package_progress` channel) and fanned out to `PiCoreVersionsSection` and `PiUpdateBadge` via a `pi-core-event` DOM event. After any successful core update the server sends `/reload` to connected pi sessions just like extension updates.
|
|
308
|
+
|
|
309
|
+
**Header badge**: `PiUpdateBadge` polls `/api/pi-core/versions` on mount + every 30 min. When `updatesAvailable > 0` it renders a small pill-shaped button next to the `ServerSelector` that navigates to `/settings?tab=packages`.
|
|
310
|
+
|
|
258
311
|
**Client navigation stack:**
|
|
259
312
|
- Puzzle icon button in folder header → PiResourcesView (content area, "Installed" / "Packages" tabs)
|
|
260
313
|
- "View" button on resource → MarkdownPreviewView (`.md` as markdown, `.ts` as code block)
|
|
@@ -429,18 +482,42 @@ The tunnel is **enabled by default** (`tunnel.enabled: true`). When the server s
|
|
|
429
482
|
|
|
430
483
|
1. **Binary detection** — `detectZrokBinary()` checks if `zrok` is on PATH via `which`/`where`
|
|
431
484
|
2. **Environment check** — `loadZrokEnv()` reads zrok's own config (`~/.zrok2/environment.json` or `~/.zrok/environment.json`) to verify enrollment. The dashboard never stores zrok API keys — they live entirely in zrok's config directory, created by `zrok enable <token>`.
|
|
432
|
-
3. **Stale cleanup** —
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
485
|
+
3. **Stale cleanup** — Runs **unconditionally on startup** whenever the zrok binary is present (even in `--no-tunnel` mode) so leftovers from a previous run are always swept:
|
|
486
|
+
- `cleanupStaleZrok()` reads `~/.pi/dashboard/zrok.pid` and SIGTERMs the tracked process
|
|
487
|
+
- `scavengeOrphanZrokProcesses(port)` scans `ps -ax` for any `zrok share … --override-endpoint http://localhost:<port>` processes that escaped pid-file tracking (previous crashes, failed retries) and SIGTERMs them. Never kills the current process.
|
|
488
|
+
4. **Reserved share** — If `tunnel.reservedToken` is not set, `zrok reserve public` is called to create a persistent share token. The token is saved to config so the URL stays the same across restarts. If a saved token fails (e.g., expired or orphaned on the zrok edge), `releaseShare(token)` explicitly releases it and a new reservation is created automatically (capped at 1 retry to prevent cascades).
|
|
489
|
+
5. **Subprocess spawn** — `createTunnel(port, reservedToken?)` spawns `zrok share reserved <token> --headless` (or `zrok share public --headless` as fallback) as a child process. Concurrent calls are serialized via an in-flight promise (`pendingCreate`) so a UI double-click or a race between startup auto-connect and `/api/tunnel-connect` can’t create two parallel reservations.
|
|
490
|
+
6. **URL parsing** — The public URL is parsed from stdout/stderr (30s timeout). On timeout: SIGTERM → SIGKILL after 2s grace, plus `releaseShare(token)` if the token was reserved just-in-time within the call (prevents leaking a dead reservation that would leave a "live but broken" URL on the zrok edge).
|
|
436
491
|
7. **PID tracking** — The subprocess PID is written to `~/.pi/dashboard/zrok.pid`
|
|
437
|
-
8. **Shutdown** — `deleteTunnel()`
|
|
492
|
+
8. **Shutdown** — `deleteTunnel(port?)` SIGTERMs the active subprocess, removes the PID file, and (when `port` is supplied) re-runs `scavengeOrphanZrokProcesses(port)` as belt-and-braces cleanup. The reserved token is preserved for next restart. Called from graceful shutdown, `/api/shutdown`, `/api/restart`, and `/api/tunnel-disconnect`.
|
|
438
493
|
|
|
439
|
-
To disable: set `tunnel.enabled` to `false` in `~/.pi/dashboard/config.json` or pass `--no-tunnel` on the CLI.
|
|
494
|
+
To disable: set `tunnel.enabled` to `false` in `~/.pi/dashboard/config.json` or pass `--no-tunnel` on the CLI. When disabled, step 3 still runs so orphan processes are cleaned up even if the tunnel is turned off.
|
|
440
495
|
|
|
441
496
|
The client can query `GET /api/tunnel-status` which returns `{ status: "active"|"inactive"|"unavailable", url?, serverOs }`.
|
|
442
497
|
The client can connect/disconnect the tunnel via `POST /api/tunnel-connect` and `POST /api/tunnel-disconnect`.
|
|
443
498
|
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
### CORS
|
|
502
|
+
|
|
503
|
+
The Fastify CORS callback in `server.ts` allows:
|
|
504
|
+
|
|
505
|
+
- Same-origin navigations (no `Origin` header).
|
|
506
|
+
- `localhost`, `127.0.0.1`, `[::1]` on any port.
|
|
507
|
+
- The currently-active zrok tunnel URL (looked up dynamically via `getTunnelUrl()` so URL rotation picks up without a restart).
|
|
508
|
+
- Any `*.share.zrok.io` host (covers stale tabs, new reservations, and the brief window before `activeTunnelUrl` is populated on startup).
|
|
509
|
+
- Explicitly-configured `corsAllowedOrigins` from config.
|
|
510
|
+
|
|
511
|
+
On a mismatch the callback returns `cb(null, false)` — **not** `cb(new Error(…), false)`. The `Error` form causes `@fastify/cors` to surface the error as HTTP 500 on every asset response, which is exactly what caused the long-running “zrok returns 500 on assets” debugging saga: Vite emits `<script type="module" crossorigin>` entry tags, which per HTML spec browsers always fetch in CORS mode (even same-origin), so the tunnel URL appearing in `Origin` is unavoidable. Returning `cb(null, false)` simply omits CORS headers; the browser enforces same-origin policy on its own.
|
|
512
|
+
|
|
513
|
+
### HTTP Compression
|
|
514
|
+
|
|
515
|
+
The Fastify server registers `@fastify/compress` globally with `gzip` + `deflate` encodings (threshold 1 KB). Brotli is intentionally **not** enabled — zrok’s free public proxy has been observed to truncate/stream-reset `content-encoding: br` responses under parallel browser load (curl succeeds, Chrome reports `ERR_ABORTED 500`). gzip round-trips cleanly through zrok and is universally supported.
|
|
516
|
+
|
|
517
|
+
Additionally, the client build generates `.gz` sibling files (via `packages/client/scripts/precompress.mjs`, run from the `build` / `prepare` scripts) and `@fastify/static` is registered with `preCompressed: true`. This serves pre-compressed assets directly with a stable `Content-Length` header, avoiding any streaming-compression edge cases in intermediate HTTP/2 proxies. Dynamic compression via `@fastify/compress` still handles API responses and other non-file routes.
|
|
518
|
+
|
|
519
|
+
Combined with client bundle splitting (see `packages/client/vite.config.ts` → `rollupOptions.output.manualChunks`), the main initial chunk ships at ~150 KB gzipped (down from 3.1 MB uncompressed), well under tunnel abort thresholds.
|
|
520
|
+
|
|
444
521
|
### PWA Support
|
|
445
522
|
|
|
446
523
|
The dashboard is installable as a Progressive Web App on mobile devices:
|
|
@@ -598,6 +675,16 @@ This is separate from the main JSON dashboard WebSocket (`/ws`).
|
|
|
598
675
|
3. Browser opens binary WS to `/ws/terminal/:id`, attaches `xterm.js`
|
|
599
676
|
4. Shell exit → PTY `onExit` → server broadcasts `terminal_removed` → card removed
|
|
600
677
|
|
|
678
|
+
**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:
|
|
679
|
+
|
|
680
|
+
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`.
|
|
681
|
+
2. **Electron bundle** — `packages/electron/scripts/bundle-server.sh` runs `find … -name spawn-helper -exec chmod +x` after `npm install` and removes macOS quarantine flags (`xattr -d com.apple.quarantine`) from native binaries.
|
|
682
|
+
3. **Runtime** — `packages/server/src/fix-pty-permissions.ts` runs once when `createTerminalManager()` is called. Uses `createRequire().resolve("node-pty")` to find the actual install location and fixes any non-executable `spawn-helper`.
|
|
683
|
+
|
|
684
|
+
A regression test (`packages/server/src/__tests__/fix-pty-permissions.test.ts`) asserts the current platform's helper is executable after install.
|
|
685
|
+
|
|
686
|
+
**Browser-gateway error visibility.** `browser-gateway.ts` distinguishes two failure modes when receiving a WebSocket frame: a `JSON.parse` error (silently dropped — garbage frames are normal on the open internet) and an exception thrown by an individual message handler (logged to stderr as `[browser-gw] handler error type=<msg.type>: <err>`). The connection stays open after handler errors so subsequent messages still flow. This stops failures like a broken `node-pty` `spawn` from manifesting as a silently dead UI button.
|
|
687
|
+
|
|
601
688
|
### Output Buffering
|
|
602
689
|
|
|
603
690
|
Each terminal maintains a 256KB ring buffer of raw PTY output. When a new WebSocket connects (reconnect, new tab), the buffer is replayed before live streaming. Combined with client-side 10,000-line scrollback.
|
|
@@ -608,7 +695,7 @@ Terminal xterm.js instances stay mounted in the DOM (CSS hidden/shown) for insta
|
|
|
608
695
|
|
|
609
696
|
### Folder-Scoped View
|
|
610
697
|
|
|
611
|
-
Terminals are displayed in a tabbed `TerminalsView` per folder, accessed via the folder action bar's `Terminals(N)` button
|
|
698
|
+
Terminals are displayed in a tabbed `TerminalsView` per folder, accessed via the folder action bar's `Terminals(N)` button. Terminal cards no longer appear in the sidebar — the sidebar shows only pi session cards. The tab bar supports switching, closing, renaming, and creating new terminals.
|
|
612
699
|
|
|
613
700
|
## Embedded Editor (code-server)
|
|
614
701
|
|
|
@@ -638,6 +725,19 @@ Browser Dashboard Server code-server
|
|
|
638
725
|
|
|
639
726
|
All code-server traffic is proxied through `/editor/:id/*` on the dashboard server. This provides same-origin access (no CORS/iframe issues) and works transparently through zrok tunnels.
|
|
640
727
|
|
|
728
|
+
### Orphan Cleanup
|
|
729
|
+
|
|
730
|
+
`EditorManager` state is purely in-memory. On graceful shutdown, `editorManager.stopAll()` SIGTERMs every child. On non-graceful shutdown (SIGKILL, crash, OOM, force-quit), spawned code-server processes get reparented to init/launchd and continue holding their port and `--user-data-dir` lockfile.
|
|
731
|
+
|
|
732
|
+
To recover, every spawn is recorded in `~/.pi/dashboard/editor-pids.json` (`editor-pid-registry.ts`). On the next server boot, `editorPidRegistry.cleanupOrphans()` runs at the top of `server.start()` (before `fastify.listen`) and:
|
|
733
|
+
|
|
734
|
+
1. Reads the persisted PIDs.
|
|
735
|
+
2. For each entry whose PID is alive AND whose OS-reported command line contains `--user-data-dir <~/.pi/dashboard/editors/...>`, sends `SIGTERM`.
|
|
736
|
+
3. After a 1 second grace period, sends `SIGKILL` to any survivor.
|
|
737
|
+
4. Rewrites the file empty.
|
|
738
|
+
|
|
739
|
+
The cmdline ownership check prevents killing unrelated `code-server` instances the user may run themselves. Cleanup completes before any `POST /api/editor/start` request can be served, so a new spawn for the same folder cannot race with a surviving orphan on the same `--user-data-dir` lockfile.
|
|
740
|
+
|
|
641
741
|
### Configuration
|
|
642
742
|
|
|
643
743
|
```json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-agent-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Web dashboard for monitoring and interacting with pi agent sessions",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,17 +46,22 @@
|
|
|
46
46
|
"LICENSE"
|
|
47
47
|
],
|
|
48
48
|
"scripts": {
|
|
49
|
+
"postinstall": "node packages/server/scripts/fix-pty-permissions.cjs",
|
|
49
50
|
"dev": "npm run dev --workspace=@blackbelt-technology/pi-dashboard-web",
|
|
50
51
|
"build": "npm run build --workspace=@blackbelt-technology/pi-dashboard-web",
|
|
51
|
-
"test": "vitest run",
|
|
52
|
-
"test:watch": "vitest",
|
|
52
|
+
"test": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run",
|
|
53
|
+
"test:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest",
|
|
53
54
|
"lint": "tsc --noEmit",
|
|
54
55
|
"reload": "./scripts/reload-all.sh",
|
|
55
56
|
"reload:check": "./scripts/reload-all.sh --check",
|
|
56
57
|
"electron:dev": "npm run start:dev --workspace=@blackbelt-technology/pi-dashboard-electron",
|
|
57
58
|
"electron:start": "npm run start --workspace=@blackbelt-technology/pi-dashboard-electron",
|
|
58
59
|
"electron:make": "npm run make --workspace=@blackbelt-technology/pi-dashboard-electron",
|
|
59
|
-
"electron:build": "bash packages/electron/scripts/build-installer.sh"
|
|
60
|
+
"electron:build": "bash packages/electron/scripts/build-installer.sh",
|
|
61
|
+
"site:dev": "npm --prefix site run dev",
|
|
62
|
+
"site:build": "npm --prefix site run build",
|
|
63
|
+
"site:preview": "npm --prefix site run preview",
|
|
64
|
+
"screenshots": "npm --prefix site run screenshots"
|
|
60
65
|
},
|
|
61
66
|
"dependencies": {
|
|
62
67
|
"@blackbelt-technology/pi-dashboard-extension": "*",
|
|
@@ -7,6 +7,8 @@ vi.mock("@sinclair/typebox", () => ({
|
|
|
7
7
|
String: vi.fn(() => ({})),
|
|
8
8
|
Optional: vi.fn((x: any) => x),
|
|
9
9
|
Array: vi.fn(() => ({})),
|
|
10
|
+
Union: vi.fn(() => ({})),
|
|
11
|
+
Literal: vi.fn(() => ({})),
|
|
10
12
|
},
|
|
11
13
|
}));
|
|
12
14
|
|
|
@@ -100,10 +102,28 @@ describe("registerAskUserTool", () => {
|
|
|
100
102
|
expect(ctx.ui.select).toHaveBeenCalledWith("Pick", ["A", "B"], undefined);
|
|
101
103
|
});
|
|
102
104
|
|
|
103
|
-
it("
|
|
105
|
+
it("throws when select reaches execute with unparseable options string", async () => {
|
|
104
106
|
const { tool, ctx } = getToolAndMockCtx();
|
|
105
|
-
await
|
|
106
|
-
|
|
107
|
+
await expect(
|
|
108
|
+
tool.execute("id", { method: "select", title: "Pick", options: "not json" }, undefined, undefined, ctx),
|
|
109
|
+
).rejects.toThrow(/options/i);
|
|
110
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("throws when select is invoked with empty options array", async () => {
|
|
114
|
+
const { tool, ctx } = getToolAndMockCtx();
|
|
115
|
+
await expect(
|
|
116
|
+
tool.execute("id", { method: "select", title: "Pick", options: [] }, undefined, undefined, ctx),
|
|
117
|
+
).rejects.toThrow(/options.*input/is);
|
|
118
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws when multiselect is invoked without options", async () => {
|
|
122
|
+
const { tool, ctx } = getToolAndMockCtx();
|
|
123
|
+
await expect(
|
|
124
|
+
tool.execute("id", { method: "multiselect", title: "Pick" }, undefined, undefined, ctx),
|
|
125
|
+
).rejects.toThrow(/options/i);
|
|
126
|
+
expect(ctx.ui.multiselect).not.toHaveBeenCalled();
|
|
107
127
|
});
|
|
108
128
|
});
|
|
109
129
|
|
|
@@ -131,5 +151,282 @@ describe("registerAskUserTool", () => {
|
|
|
131
151
|
const result = tool.prepareArguments({ method: "select", title: "Pick", options: "not json" });
|
|
132
152
|
expect(result.options).toBe("not json");
|
|
133
153
|
});
|
|
154
|
+
|
|
155
|
+
it("unwraps stringified params wrapper", () => {
|
|
156
|
+
const tool = getTool();
|
|
157
|
+
const result = tool.prepareArguments({
|
|
158
|
+
method: "select",
|
|
159
|
+
params: '{"title":"X","options":["a","b"]}',
|
|
160
|
+
});
|
|
161
|
+
expect(result.method).toBe("select");
|
|
162
|
+
expect(result.title).toBe("X");
|
|
163
|
+
expect(result.options).toEqual(["a", "b"]);
|
|
164
|
+
expect(result.params).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("unwraps object-form params wrapper", () => {
|
|
168
|
+
const tool = getTool();
|
|
169
|
+
const result = tool.prepareArguments({
|
|
170
|
+
method: "select",
|
|
171
|
+
params: { title: "X", options: ["a", "b"] },
|
|
172
|
+
});
|
|
173
|
+
expect(result.method).toBe("select");
|
|
174
|
+
expect(result.title).toBe("X");
|
|
175
|
+
expect(result.options).toEqual(["a", "b"]);
|
|
176
|
+
expect(result.params).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("copies question into title when title is absent", () => {
|
|
180
|
+
const tool = getTool();
|
|
181
|
+
const result = tool.prepareArguments({ method: "input", question: "Your name?" });
|
|
182
|
+
expect(result.title).toBe("Your name?");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("does not overwrite explicit title with question", () => {
|
|
186
|
+
const tool = getTool();
|
|
187
|
+
const result = tool.prepareArguments({ method: "input", title: "T", question: "Q" });
|
|
188
|
+
expect(result.title).toBe("T");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("top-level fields win over params wrapper", () => {
|
|
192
|
+
const tool = getTool();
|
|
193
|
+
const result = tool.prepareArguments({
|
|
194
|
+
method: "select",
|
|
195
|
+
title: "OuterTitle",
|
|
196
|
+
params: { title: "InnerTitle", options: ["a", "b"] },
|
|
197
|
+
});
|
|
198
|
+
expect(result.title).toBe("OuterTitle");
|
|
199
|
+
expect(result.options).toEqual(["a", "b"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("rescues options JSON string inside params wrapper", () => {
|
|
203
|
+
const tool = getTool();
|
|
204
|
+
const result = tool.prepareArguments({
|
|
205
|
+
method: "select",
|
|
206
|
+
params: '{"title":"X","options":"[\\"a\\",\\"b\\"]"}',
|
|
207
|
+
});
|
|
208
|
+
expect(result.options).toEqual(["a", "b"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── batch rescue ────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
it("parses stringified questions array and synthesizes method=batch", () => {
|
|
214
|
+
const tool = getTool();
|
|
215
|
+
const result = tool.prepareArguments({
|
|
216
|
+
questions:
|
|
217
|
+
'[{"title":"Pick","method":"select","options":["a","b"]}]',
|
|
218
|
+
});
|
|
219
|
+
expect(result.method).toBe("batch");
|
|
220
|
+
expect(Array.isArray(result.questions)).toBe(true);
|
|
221
|
+
expect(result.questions).toHaveLength(1);
|
|
222
|
+
expect(result.title).toBe("Pick");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("backfills missing outer title on explicit method=batch call", () => {
|
|
226
|
+
const tool = getTool();
|
|
227
|
+
const result = tool.prepareArguments({
|
|
228
|
+
method: "batch",
|
|
229
|
+
questions: [
|
|
230
|
+
{ method: "confirm", question: "Proceed?" },
|
|
231
|
+
{ method: "select", question: "Scope?", options: ["A", "B"] },
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
expect(result.title).toBe("Proceed?");
|
|
235
|
+
expect(result.questions[0].title).toBe("Proceed?"); // sub-question rename also fired
|
|
236
|
+
expect(result.questions[1].title).toBe("Scope?");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("bare questions array with no method synthesizes method=batch and pulls title", () => {
|
|
240
|
+
const tool = getTool();
|
|
241
|
+
const result = tool.prepareArguments({
|
|
242
|
+
questions: [{ method: "confirm", title: "Proceed?" }],
|
|
243
|
+
});
|
|
244
|
+
expect(result.method).toBe("batch");
|
|
245
|
+
expect(result.title).toBe("Proceed?");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("pulls title from question or header if sub-question lacks title", () => {
|
|
249
|
+
const tool = getTool();
|
|
250
|
+
const result = tool.prepareArguments({
|
|
251
|
+
questions: [{ method: "input", question: "Your name?" }],
|
|
252
|
+
});
|
|
253
|
+
expect(result.method).toBe("batch");
|
|
254
|
+
expect(result.title).toBe("Your name?");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("flattens input_type wrapper inside a sub-question", () => {
|
|
258
|
+
const tool = getTool();
|
|
259
|
+
const result = tool.prepareArguments({
|
|
260
|
+
method: "batch",
|
|
261
|
+
title: "T",
|
|
262
|
+
questions: [
|
|
263
|
+
{
|
|
264
|
+
title: "Pick",
|
|
265
|
+
input_type: { method: "select", options: ["a", "b"] },
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
const sq = result.questions[0];
|
|
270
|
+
expect(sq.method).toBe("select");
|
|
271
|
+
expect(sq.options).toEqual(["a", "b"]);
|
|
272
|
+
expect(sq.input_type).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("converts {label, value} options to labels and records a warning", () => {
|
|
276
|
+
const tool = getTool();
|
|
277
|
+
const result = tool.prepareArguments({
|
|
278
|
+
method: "batch",
|
|
279
|
+
title: "T",
|
|
280
|
+
questions: [
|
|
281
|
+
{
|
|
282
|
+
method: "select",
|
|
283
|
+
title: "Pick",
|
|
284
|
+
options: [
|
|
285
|
+
{ label: "Sync now", value: "sync" },
|
|
286
|
+
{ label: "Skip", value: "skip" },
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
});
|
|
291
|
+
expect(result.questions[0].options).toEqual(["Sync now", "Skip"]);
|
|
292
|
+
const warnings = (result as any).__normalizations as string[];
|
|
293
|
+
expect(warnings).toBeDefined();
|
|
294
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
295
|
+
expect(warnings[0]).toMatch(/label.*value/);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("renames sub-question header to title", () => {
|
|
299
|
+
const tool = getTool();
|
|
300
|
+
const result = tool.prepareArguments({
|
|
301
|
+
method: "batch",
|
|
302
|
+
title: "T",
|
|
303
|
+
questions: [{ method: "input", header: "Enter name" }],
|
|
304
|
+
});
|
|
305
|
+
expect(result.questions[0].title).toBe("Enter name");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("batch execution", () => {
|
|
310
|
+
function getToolAndMockCtx() {
|
|
311
|
+
const pi = createMockPi();
|
|
312
|
+
registerAskUserTool(pi as any);
|
|
313
|
+
const tool = pi.registerTool.mock.calls[0][0];
|
|
314
|
+
const ctx = {
|
|
315
|
+
ui: {
|
|
316
|
+
confirm: vi.fn().mockResolvedValue(true),
|
|
317
|
+
select: vi.fn().mockResolvedValue("A"),
|
|
318
|
+
input: vi.fn().mockResolvedValue("hello"),
|
|
319
|
+
multiselect: vi.fn().mockResolvedValue(["A"]),
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
return { tool, ctx };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it("invokes ctx.ui primitives sequentially for each sub-question", async () => {
|
|
326
|
+
const { tool, ctx } = getToolAndMockCtx();
|
|
327
|
+
const result = await tool.execute(
|
|
328
|
+
"id",
|
|
329
|
+
{
|
|
330
|
+
method: "batch",
|
|
331
|
+
title: "Setup",
|
|
332
|
+
questions: [
|
|
333
|
+
{ method: "input", title: "Name?" },
|
|
334
|
+
{ method: "select", title: "Lang?", options: ["TS", "Py"] },
|
|
335
|
+
{ method: "confirm", title: "Init git?" },
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
undefined,
|
|
339
|
+
undefined,
|
|
340
|
+
ctx,
|
|
341
|
+
);
|
|
342
|
+
expect(ctx.ui.input).toHaveBeenCalledTimes(1);
|
|
343
|
+
expect(ctx.ui.select).toHaveBeenCalledTimes(1);
|
|
344
|
+
expect(ctx.ui.confirm).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(result.details.method).toBe("batch");
|
|
346
|
+
expect(result.details.results).toEqual(["hello", "A", true]);
|
|
347
|
+
expect(result.details.cancelled).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("prepends batch title to sub-question titles", async () => {
|
|
351
|
+
const { tool, ctx } = getToolAndMockCtx();
|
|
352
|
+
await tool.execute(
|
|
353
|
+
"id",
|
|
354
|
+
{
|
|
355
|
+
method: "batch",
|
|
356
|
+
title: "Setup",
|
|
357
|
+
questions: [{ method: "input", title: "Name?" }],
|
|
358
|
+
},
|
|
359
|
+
undefined,
|
|
360
|
+
undefined,
|
|
361
|
+
ctx,
|
|
362
|
+
);
|
|
363
|
+
const firstCallTitle = ctx.ui.input.mock.calls[0][0];
|
|
364
|
+
expect(firstCallTitle).toContain("Setup");
|
|
365
|
+
expect(firstCallTitle).toContain("Name?");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("stops on cancellation and returns partial results with cancelled=true", async () => {
|
|
369
|
+
const { tool, ctx } = getToolAndMockCtx();
|
|
370
|
+
// First sub-question returns a value; second cancels (undefined); third should not be called.
|
|
371
|
+
ctx.ui.input.mockResolvedValueOnce("first");
|
|
372
|
+
ctx.ui.select.mockResolvedValueOnce(undefined); // cancel
|
|
373
|
+
const result = await tool.execute(
|
|
374
|
+
"id",
|
|
375
|
+
{
|
|
376
|
+
method: "batch",
|
|
377
|
+
title: "T",
|
|
378
|
+
questions: [
|
|
379
|
+
{ method: "input", title: "Q1" },
|
|
380
|
+
{ method: "select", title: "Q2", options: ["a", "b"] },
|
|
381
|
+
{ method: "confirm", title: "Q3" },
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
undefined,
|
|
385
|
+
undefined,
|
|
386
|
+
ctx,
|
|
387
|
+
);
|
|
388
|
+
expect(result.details.cancelled).toBe(true);
|
|
389
|
+
expect(result.details.results).toEqual(["first", null]);
|
|
390
|
+
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("surfaces __normalizations warnings in details.warnings", async () => {
|
|
394
|
+
const { tool, ctx } = getToolAndMockCtx();
|
|
395
|
+
const prepared = tool.prepareArguments({
|
|
396
|
+
method: "batch",
|
|
397
|
+
title: "T",
|
|
398
|
+
questions: [
|
|
399
|
+
{
|
|
400
|
+
method: "select",
|
|
401
|
+
title: "Pick",
|
|
402
|
+
options: [
|
|
403
|
+
{ label: "A", value: "a" },
|
|
404
|
+
{ label: "B", value: "b" },
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
});
|
|
409
|
+
const result = await tool.execute("id", prepared, undefined, undefined, ctx);
|
|
410
|
+
expect(result.details.warnings).toBeDefined();
|
|
411
|
+
expect(result.details.warnings.length).toBeGreaterThan(0);
|
|
412
|
+
expect(result.details.warnings[0]).toMatch(/label.*value/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("throws if a batch sub-question is select with empty options", async () => {
|
|
416
|
+
const { tool, ctx } = getToolAndMockCtx();
|
|
417
|
+
await expect(
|
|
418
|
+
tool.execute(
|
|
419
|
+
"id",
|
|
420
|
+
{
|
|
421
|
+
method: "batch",
|
|
422
|
+
title: "T",
|
|
423
|
+
questions: [{ method: "select", title: "Pick", options: [] }],
|
|
424
|
+
},
|
|
425
|
+
undefined,
|
|
426
|
+
undefined,
|
|
427
|
+
ctx,
|
|
428
|
+
),
|
|
429
|
+
).rejects.toThrow(/options/i);
|
|
430
|
+
});
|
|
134
431
|
});
|
|
135
432
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for fork entryId timing fix.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the bridge's message_end entryId enrichment captures the
|
|
5
|
+
* correct leaf ID (after pi core persists the entry), not the stale one
|
|
6
|
+
* (before appendMessage runs).
|
|
7
|
+
*
|
|
8
|
+
* The bug: pi core emits message_end via _emit() BEFORE calling
|
|
9
|
+
* sessionManager.appendMessage(), so getLeafId() returns the previous leaf.
|
|
10
|
+
* The fix: the bridge defers getLeafId() for message_end using queueMicrotask,
|
|
11
|
+
* allowing appendMessage to run first.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Simulates the pi core + bridge interaction for entryId enrichment.
|
|
17
|
+
*
|
|
18
|
+
* Pi core's _processAgentEvent does:
|
|
19
|
+
* 1. _emit(event) — bridge handler called (async, not awaited)
|
|
20
|
+
* 2. appendMessage(msg) — updates leafId synchronously
|
|
21
|
+
*
|
|
22
|
+
* The bridge handler (async) should yield via queueMicrotask before reading
|
|
23
|
+
* getLeafId(), so that appendMessage has already run.
|
|
24
|
+
*/
|
|
25
|
+
describe("message_end entryId timing", () => {
|
|
26
|
+
it("deferred getLeafId() captures the post-persist entry ID", async () => {
|
|
27
|
+
// Simulate sessionManager with mutable leafId
|
|
28
|
+
let leafId = "user-entry-100"; // stale leaf before appendMessage
|
|
29
|
+
const sessionManager = {
|
|
30
|
+
getLeafId: () => leafId,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let capturedEntryId: string | undefined;
|
|
34
|
+
|
|
35
|
+
// Simulate the bridge handler (with the fix: defers via queueMicrotask)
|
|
36
|
+
const bridgeHandler = async () => {
|
|
37
|
+
// This is what the fixed bridge does for message_end:
|
|
38
|
+
await new Promise<void>(resolve => queueMicrotask(resolve));
|
|
39
|
+
capturedEntryId = sessionManager.getLeafId();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Simulate pi core's _processAgentEvent:
|
|
43
|
+
// 1. _emit calls handler (async, NOT awaited)
|
|
44
|
+
const handlerPromise = bridgeHandler();
|
|
45
|
+
// 2. appendMessage runs synchronously, updating leafId
|
|
46
|
+
leafId = "assistant-entry-101";
|
|
47
|
+
|
|
48
|
+
// Wait for the deferred handler to complete
|
|
49
|
+
await handlerPromise;
|
|
50
|
+
|
|
51
|
+
expect(capturedEntryId).toBe("assistant-entry-101");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("immediate getLeafId() would capture the stale entry ID (demonstrates the bug)", async () => {
|
|
55
|
+
let leafId = "user-entry-100";
|
|
56
|
+
const sessionManager = {
|
|
57
|
+
getLeafId: () => leafId,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let capturedEntryId: string | undefined;
|
|
61
|
+
|
|
62
|
+
// Simulate the OLD (buggy) bridge handler: reads getLeafId() immediately
|
|
63
|
+
const buggyBridgeHandler = async () => {
|
|
64
|
+
// No deferral — reads leafId before appendMessage runs
|
|
65
|
+
capturedEntryId = sessionManager.getLeafId();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Simulate pi core's _processAgentEvent:
|
|
69
|
+
const handlerPromise = buggyBridgeHandler();
|
|
70
|
+
leafId = "assistant-entry-101"; // too late — handler already read it
|
|
71
|
+
|
|
72
|
+
await handlerPromise;
|
|
73
|
+
|
|
74
|
+
// Bug: captures the stale leaf, not the assistant's entry
|
|
75
|
+
expect(capturedEntryId).toBe("user-entry-100");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("message_start should still capture entryId immediately (no deferral)", async () => {
|
|
79
|
+
let leafId = "previous-assistant-entry-99";
|
|
80
|
+
const sessionManager = {
|
|
81
|
+
getLeafId: () => leafId,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let capturedEntryId: string | undefined;
|
|
85
|
+
|
|
86
|
+
// Simulate bridge handler for message_start (immediate, no deferral)
|
|
87
|
+
const messageStartHandler = async () => {
|
|
88
|
+
capturedEntryId = sessionManager.getLeafId();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handlerPromise = messageStartHandler();
|
|
92
|
+
// User entry gets written after message_start
|
|
93
|
+
leafId = "user-entry-100";
|
|
94
|
+
|
|
95
|
+
await handlerPromise;
|
|
96
|
+
|
|
97
|
+
// message_start should capture the leaf BEFORE the user entry is written
|
|
98
|
+
expect(capturedEntryId).toBe("previous-assistant-entry-99");
|
|
99
|
+
});
|
|
100
|
+
});
|