feat(dsh-plugin-browserskill): host-side ObservationService for the PiP overlay (Phase 1-A)

Per-session live observation for owned sessions only, feeding the
upcoming client overlay:

- State model: SessionObservation (sessionId/url/action/since/
  thumbnailAttachmentId/lastError), one entry per owned session;
  added on start, removed on stop, cleared on dispose.
- Instrumentation: the shared runBsk wrapper begins/ends an action
  (tool name mapped to a verb) around every model-facing call and tags
  the child with its session id; action end triggers an immediate
  thumbnail refresh.
- Thumbnail loop: 1.5s cadence while active, 8s idle downclock, x3
  failure backoff, silent frame retention on errors; captures run
  OUTSIDE the instrumentation (no action events, no current-pointer
  movement) and land in the attachment store by reference.
- Interrupt: runner.killFor(tag) kills exactly the tagged in-flight bsk
  children — chat-Stop-equivalent semantics for the current or a named
  owned session; foreign ids and the no-in-flight case return false.
- Remote seam: dsh 0.1's Typert Remote pipeline and forwarded-event
  allowlist are closed to out-of-tree packages, so state/events/
  interrupt are served over the documented webServer route seam:
  GET /bsk-observation/state, GET /bsk-observation/events (SSE),
  POST /bsk-observation/interrupt; routes mount only when a webServer
  service exists (headless compositions skip them).
- Config: observationEnabled (true), thumbnailIntervalMs (1500),
  idleIntervalMs (8000).

Tests (+13, 72 total): state machine + event stream, cadence fast/idle/
backoff, headless no-store silence, observation-traffic isolation,
interrupt routing (default/specified/none/foreign), HTTP route
handlers, disabled-webServer no-op.
This commit is contained in:
BB-fat
2026-08-14 04:59:26 +00:00
parent 3d050e0f26
commit a7bc8bacf6
8 changed files with 989 additions and 31 deletions
+22 -3
View File
@@ -53,17 +53,26 @@ export interface BskRunOptions {
/** Minimal spawn signature so tests can substitute a fake child process. */
export type SpawnImpl = (command: string, args: string[]) => ChildProcess;
export interface BskRunOptions {
signal?: AbortSignal;
timeoutMs?: number;
/** Opaque routing tag (e.g. a session id) enabling per-tag kills. */
tag?: string;
}
export interface BskRunner {
/** Run `bsk <args...> --json` and collect its output. */
run(args: string[], options?: BskRunOptions): Promise<BskRunResult>;
/** Kill every in-flight child (used when the plugin unloads). */
killAll(): void;
/** Kill only the in-flight children carrying this tag; returns how many were killed. */
killFor(tag: string): number;
}
const KILL_GRACE_MS = 2000;
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
const live = new Set<ChildProcess>();
const live = new Map<ChildProcess, string | undefined>();
function killChild(child: ChildProcess): void {
if (child.exitCode !== null || child.signalCode !== null) return;
@@ -84,7 +93,7 @@ export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn):
reject(error);
return;
}
live.add(child);
live.set(child, options.tag);
let stdout = "";
let stderr = "";
@@ -134,7 +143,17 @@ export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn):
});
},
killAll() {
for (const child of live) killChild(child);
for (const child of live.keys()) killChild(child);
},
killFor(tag: string) {
let killed = 0;
for (const [child, childTag] of live) {
if (childTag === tag) {
killChild(child);
killed += 1;
}
}
return killed;
},
};
}