@caiqy/browser-skill-dsh-plugin 0.0.0-stage → 0.3.3-dsh.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,493 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import "@deepseek-ai/dsh-tools";
3
+ import { ChildProcess, SpawnOptionsWithoutStdio } from "node:child_process";
4
+ import { Context } from "@deepseek-ai/cordis";
5
+ //#region src/runner.d.ts
6
+ /** A failed `bsk` invocation (non-zero exit, timeout, or spawn failure). */
7
+ declare class BskError extends Error {
8
+ readonly code?: string;
9
+ readonly hint?: string;
10
+ readonly exitCode?: number | null;
11
+ readonly timedOut: boolean;
12
+ constructor(message: string, options?: {
13
+ code?: string;
14
+ hint?: string;
15
+ exitCode?: number | null;
16
+ timedOut?: boolean;
17
+ });
18
+ }
19
+ interface BskRunResult {
20
+ /** Process exit code, or null when killed by a signal / never started. */
21
+ code: number | null;
22
+ stdout: string;
23
+ stderr: string;
24
+ timedOut: boolean;
25
+ aborted: boolean;
26
+ }
27
+ interface BskRunOptions {
28
+ signal?: AbortSignal;
29
+ /** Execution timeout; after exit, output collection has a separate 2s limit. */
30
+ timeoutMs?: number;
31
+ /** Opaque routing tag (e.g. a session id) enabling per-tag kills. */
32
+ tag?: string;
33
+ }
34
+ /** Minimal spawn signature so tests can substitute a fake child process. */
35
+ type SpawnImpl = (command: string, args: string[], options?: SpawnOptionsWithoutStdio) => ChildProcess;
36
+ interface BskRunner {
37
+ /** Run `bsk <args...> --json` and collect its output. */
38
+ run(args: string[], options?: BskRunOptions): Promise<BskRunResult>;
39
+ /** Kill every still-running child (used when the plugin unloads). */
40
+ killAll(): void;
41
+ /** Kill running children carrying this tag; returns the number matched. */
42
+ killFor(tag: string): number;
43
+ }
44
+ declare function createBskRunner(bskPath: string, spawnImpl?: SpawnImpl): BskRunner;
45
+ //#endregion
46
+ //#region src/start-journal.d.ts
47
+ interface StartRecord {
48
+ requestId: string;
49
+ owners: string[];
50
+ startedAtMs: number;
51
+ cleanup: boolean;
52
+ /** An explicit stop stays retryable until its caller acknowledges completion. */
53
+ stop?: "pending" | "closed";
54
+ /** A failed implicit waiter advances this revision; other callers cannot consume its retry. */
55
+ defaultStopRevision?: number;
56
+ session?: {
57
+ sessionId: string;
58
+ browserInstanceId: string;
59
+ };
60
+ }
61
+ interface StartJournal {
62
+ records: Map<string, StartRecord>;
63
+ save(): void;
64
+ release(): void;
65
+ }
66
+ //#endregion
67
+ //#region src/queue.d.ts
68
+ /**
69
+ * Per-key FIFO executor. The bsk daemon serializes commands per session (a
70
+ * second command while one is unfinished is rejected), so every plugin
71
+ * command — model-facing tool calls AND observation captures — funnels
72
+ * through one queue per session. A queued task rejects early if its abort
73
+ * signal fires before it starts; once running, cancellation is owned by the
74
+ * task's own signal handling (the runner kills the child).
75
+ */
76
+ declare class KeyedExecutor {
77
+ private readonly tails;
78
+ /** Run `fn` after every previously queued task for `key` settled. */
79
+ run<T>(key: string, fn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
80
+ }
81
+ //#endregion
82
+ //#region src/sessions.d.ts
83
+ /**
84
+ * Tracks the bsk sessions this plugin created and the "current" session
85
+ * pointer used when a tool call omits its optional `session` arg. The pointer
86
+ * moves to whatever session was most recently started, stopped, or operated
87
+ * on, so a model can run several browsers side by side without repeating the
88
+ * id on every call.
89
+ *
90
+ * Strict ownership boundary (the bsk daemon may be shared with other agents,
91
+ * terminals, or dsh instances): the registry ONLY ever holds sessions created
92
+ * by this plugin's bsk_session start action. Tools cannot see or act on any
93
+ * other session — an explicit `session` argument naming a foreign id is an
94
+ * error, the list tool shows owned sessions only, and stop/unload cleanup
95
+ * can never touch a session this plugin did not create.
96
+ */
97
+ interface TrackedSession {
98
+ sessionId: string;
99
+ /** Stable lifecycle handle, retained even if the short session ID is reused. */
100
+ requestId?: string;
101
+ browserInstanceId?: string;
102
+ startedAtMs: number;
103
+ /** Always true: only plugin-created sessions enter the registry at all. */
104
+ owned: boolean;
105
+ state: "starting" | "active" | "cleanup";
106
+ }
107
+ type NewSession = Omit<TrackedSession, "owned" | "state">;
108
+ declare class SessionRegistry {
109
+ private readonly maxSessions;
110
+ private readonly sessions;
111
+ private currentId;
112
+ /**
113
+ * bsk session id → the DSH session ids that may clean it up: the agent
114
+ * session that started it plus every ancestor along the seed lineage, so
115
+ * archiving a conversation at ANY level of the chain reaps its browsers
116
+ * (see archive-cleanup.ts).
117
+ */
118
+ private readonly dshOwners;
119
+ /**
120
+ * Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart
121
+ * run synchronously around the async spawn, so concurrent starts can never
122
+ * both pass the capacity check (check-and-reserve is atomic on the event loop).
123
+ */
124
+ private pendingStarts;
125
+ constructor(maxSessions: number);
126
+ /**
127
+ * Reserve a start slot synchronously, BEFORE spawning.
128
+ * @throws when the configured concurrency cap (tracked + in-flight) is reached.
129
+ */
130
+ reserveStart(recoveredPending?: number): void;
131
+ /** Give back a reservation after a start that never produced a session. */
132
+ abandonStart(): void;
133
+ /**
134
+ * Register a freshly started session, consuming its reservation, and make
135
+ * it current.
136
+ */
137
+ completeStart(session: NewSession): void;
138
+ /** Own a resource without exposing it to ordinary browser commands. */
139
+ trackStart(session: NewSession, state?: "starting" | "cleanup"): void;
140
+ /** Publish only after initialization and the daemon claim have succeeded. */
141
+ activate(sessionId: string): void;
142
+ markForCleanup(sessionId: string): void;
143
+ private selectCurrent;
144
+ isUsable(sessionId: string): boolean;
145
+ stateFor(sessionId: string): TrackedSession["state"] | undefined;
146
+ assertUsable(sessionId: string, toolName: string): void;
147
+ /** Forget a session; falls back to the most recent remaining one. */
148
+ remove(sessionId: string): void;
149
+ /**
150
+ * Record which DSH conversation(s) a freshly started bsk session belongs
151
+ * to (the starting agent's session plus its ancestors). No-op without ids
152
+ * — e.g. a start whose caller carried no agent identity.
153
+ */
154
+ trackOwner(sessionId: string, dshSessionIds: readonly string[]): void;
155
+ /** bsk session ids owned by the given DSH conversation (or its descendants). */
156
+ ownedByDsh(dshSessionId: string): string[];
157
+ /** The DSH conversation ids owning one bsk session (empty when untracked). */
158
+ dshOwnersOf(sessionId: string): string[];
159
+ /** Mark an owned session as most recently used (recency order refresh). */
160
+ private touch;
161
+ /** The current session id, if any. */
162
+ current(): string | undefined;
163
+ /** Owned sessions in least- to most-recently-used order. */
164
+ list(): TrackedSession[];
165
+ /** Ids of owned sessions — the exact set unload cleanup is allowed to stop. */
166
+ ownedIds(): string[];
167
+ /** Whether the session was created by this plugin. */
168
+ isOwned(sessionId: string): boolean;
169
+ requestFor(sessionId: string): string | undefined;
170
+ size(): number;
171
+ /** Shared not-yours error for foreign or unknown session ids. */
172
+ private foreignError;
173
+ /**
174
+ * Resolve the session a tool call acts on: an explicit `session` argument
175
+ * must name an owned session (and becomes current); omitted falls back to
176
+ * the current session. Foreign ids are rejected, never adopted.
177
+ * @throws on foreign/unknown ids, or when no session exists.
178
+ */
179
+ resolve(explicit: string | undefined, toolName: string): string;
180
+ /**
181
+ * Stop can also reach starting/cleanup resources. If no usable session is
182
+ * current, default to the most recent owned resource so cleanup is retryable.
183
+ * A rejected stop never moves the current pointer.
184
+ */
185
+ resolveForStop(explicit: string | undefined): string;
186
+ }
187
+ //#endregion
188
+ //#region src/observation.d.ts
189
+ /** One owned session's live observation record (wire-stable shape). */
190
+ interface SessionObservation {
191
+ sessionId: string;
192
+ /** Last settled page URL, when any navigation completed. */
193
+ url?: string;
194
+ /** Current action ('idle' when nothing is in flight). */
195
+ action: string;
196
+ /** Epoch ms when the current action started (elapsed time is client-side). */
197
+ since: number;
198
+ /** Latest thumbnail id (ephemeral, plugin-owned; never bytes on the wire). */
199
+ thumbnailAttachmentId?: string;
200
+ /** Summary of the most recent failed action (drives the red status dot). */
201
+ lastError?: string;
202
+ /**
203
+ * The daemon reports this session as gone (e.g. daemon restart): the strip
204
+ * greys it out until it is stopped/removed. No more frames are requested.
205
+ */
206
+ dead?: boolean;
207
+ /**
208
+ * The DSH conversations this session belongs to (the starting agent's
209
+ * session plus its seed-lineage ancestors), recorded at start. Scoped
210
+ * surfaces (the native sidebar tab) filter by it; absent means untracked
211
+ * ownership — visible only in the global (unscoped) view.
212
+ */
213
+ dshSessionIds?: string[];
214
+ }
215
+ /** Initial snapshot or incremental change carried to subscribers (SSE on the wire). */
216
+ type ObservationEvent = {
217
+ type: "snapshot";
218
+ sessions: SessionObservation[];
219
+ available: boolean;
220
+ } | {
221
+ type: "upsert" | "remove" | "reset";
222
+ session?: SessionObservation;
223
+ } | {
224
+ type: "availability";
225
+ available: boolean;
226
+ };
227
+ interface ObservationOptions {
228
+ enabled: boolean;
229
+ /** Fast cadence while a session is active or recently was. */
230
+ thumbnailIntervalMs: number;
231
+ /** Slow cadence for idle sessions; also the "recently active" window. */
232
+ idleIntervalMs: number;
233
+ }
234
+ /** Injectable clock/scheduler bits for tests. */
235
+ interface ObservationScheduler {
236
+ setTimeout: (fn: () => void, ms: number) => unknown;
237
+ clearTimeout: (handle: unknown) => void;
238
+ now: () => number;
239
+ }
240
+ declare class ObservationService {
241
+ private readonly deps;
242
+ private readonly scratchNamespace;
243
+ private readonly observations;
244
+ private readonly listeners;
245
+ private thumbnailViewers;
246
+ private readonly captureTimers;
247
+ private readonly captureInFlight;
248
+ /** Captures intentionally cancelled to make way for foreground work. */
249
+ private readonly capturePreempted;
250
+ /** Number of queued/running model-facing calls that currently outrank thumbnails. */
251
+ private readonly foregroundDepth;
252
+ private readonly captureFailures;
253
+ private readonly lastActivity;
254
+ /** Ephemeral frame id → PNG bytes. Only the live ring members are present. */
255
+ private readonly frames;
256
+ /** sessionId → oldest-first frame ids, length ≤ FRAME_RING_SIZE. */
257
+ private readonly rings;
258
+ /** sessionId → sha256 of the current frame (skip republish when unchanged). */
259
+ private readonly hashes;
260
+ /** sessionId → last issued sequence number. */
261
+ private readonly seqs;
262
+ /** Consecutive capture failures across ALL sessions (daemon-level signal). */
263
+ private globalFailures;
264
+ private available;
265
+ private disposed;
266
+ constructor(deps: {
267
+ ctx: Context;
268
+ runner: BskRunner;
269
+ registry: SessionRegistry;
270
+ queue: KeyedExecutor;
271
+ options: ObservationOptions;
272
+ scheduler?: ObservationScheduler;
273
+ });
274
+ private get scheduler();
275
+ /** One-time state read; use subscribe for an ordered snapshot and subsequent changes. */
276
+ getState(): SessionObservation[];
277
+ /** Whether the browser side looks reachable (drives the "browser unavailable" state). */
278
+ isAvailable(): boolean;
279
+ private setAvailable;
280
+ /**
281
+ * Subscribe to an ordered initial snapshot and subsequent changes. On an
282
+ * active service, listener receives the snapshot synchronously before this
283
+ * method returns. Subscribing after disposal is a no-op.
284
+ *
285
+ * `thumbnails` defaults to true for compatibility: this subscription counts
286
+ * as a screenshot viewer for the service's owned sessions. Pass false for
287
+ * state-only observation. The first viewer starts capture scheduling.
288
+ *
289
+ * @returns An idempotent unsubscribe function releasing this subscription's
290
+ * screenshot demand. When the last viewer leaves, scheduled/queued captures
291
+ * are cancelled/skipped; an already running capture may finish.
292
+ */
293
+ subscribe(listener: (event: ObservationEvent) => void, { thumbnails }?: {
294
+ thumbnails?: boolean;
295
+ }): () => void;
296
+ private emit;
297
+ private put;
298
+ /** Register a fresh owned session (called from bsk_session action=start). */
299
+ addSession(sessionId: string, url?: string): void;
300
+ /** Drop a session (called from bsk_session action=stop). */
301
+ removeSession(sessionId: string): void;
302
+ /**
303
+ * Give model-facing work priority over the best-effort thumbnail lane.
304
+ * The first lease cancels a pending timer and gracefully interrupts an
305
+ * active bsk screenshot; the last release schedules one fresh frame.
306
+ */
307
+ acquireForeground(sessionId: string): () => void;
308
+ /** Mark an action starting on a session (tool entry instrumentation). */
309
+ beginAction(sessionId: string, action: string): void;
310
+ /**
311
+ * Mark the current action settling (tool exit instrumentation). Triggers an
312
+ * immediate thumbnail refresh — action-driven first, timer as fallback.
313
+ */
314
+ endAction(sessionId: string, error?: string): void;
315
+ /** Record the settled page URL (navigate success / start with url). */
316
+ setUrl(sessionId: string, url: string): void;
317
+ /**
318
+ * Interrupt the in-flight call of one session (default: the registry's
319
+ * current). Kills exactly the bsk children this plugin spawned for that
320
+ * session — same user-visible semantics as the chat Stop button (the in-flight
321
+ * tool call fails; the agent flow may continue).
322
+ * @returns whether an in-flight call was actually interrupted.
323
+ */
324
+ interrupt(sessionId?: string): boolean;
325
+ /**
326
+ * Read one captured thumbnail from the in-process ring. Powers the plugin's
327
+ * own HTTP thumbnail route — frames are plugin-owned runtime data, never
328
+ * referenced by any session log, so the session-authorized client RPC
329
+ * cannot serve them.
330
+ */
331
+ readThumbnail(attachmentId: string): Promise<{
332
+ data: Uint8Array;
333
+ mediaType: string;
334
+ } | undefined>;
335
+ /** Tear down all state and timers (plugin dispose). */
336
+ dispose(): void;
337
+ private cancelCapture;
338
+ private canCapture;
339
+ private restingAction;
340
+ /** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
341
+ private scheduleCapture;
342
+ /**
343
+ * Capture one frame: `bsk screenshot --json` through the runner, bytes into
344
+ * the in-process ring, id onto the observation. Runs OUTSIDE the tool
345
+ * instrumentation on purpose — no action events, no registry writes.
346
+ * Failures keep the previous frame and back off silently.
347
+ */
348
+ private capture;
349
+ /** Insert a new frame, or no-op when the PNG bytes match the current one. */
350
+ private publishFrame;
351
+ private dropSessionFrames;
352
+ }
353
+ //#endregion
354
+ //#region src/session-starts.d.ts
355
+ interface StopOptions {
356
+ sessionId?: string;
357
+ /** Exact target for recovery, including starts that never returned a session ID. */
358
+ requestId?: string;
359
+ signal?: AbortSignal;
360
+ }
361
+ interface StopResult {
362
+ stopped: string;
363
+ requestId: string;
364
+ alreadyClosed: boolean;
365
+ }
366
+ /** The one lifecycle owner for pending, live, and failed-cleanup starts. */
367
+ declare class SessionStarts {
368
+ private readonly deps;
369
+ readonly journal: StartJournal;
370
+ private closing;
371
+ private timer?;
372
+ private readonly cleaning;
373
+ private readonly reserved;
374
+ constructor(deps: ToolDeps, journal?: StartJournal);
375
+ begin(owners: string[]): StartRecord;
376
+ prepare(record: StartRecord, signal: AbortSignal): Promise<void>;
377
+ assertStarting(record: StartRecord): void;
378
+ register(record: StartRecord, reply: {
379
+ session_id: string;
380
+ browser_instance_id: string;
381
+ }): void;
382
+ private adopt;
383
+ claim(record: StartRecord, signal: AbortSignal): Promise<void>;
384
+ /** Accept a durable stop; aborting its caller only cancels waiting, never cleanup. */
385
+ stop({ sessionId, requestId, signal }?: StopOptions): Promise<StopResult>;
386
+ private resolveStop;
387
+ private requestCleanup;
388
+ fail(record: StartRecord): Promise<void>;
389
+ private cancel;
390
+ private cancelOnce;
391
+ private releaseResource;
392
+ private completeCleanup;
393
+ private forget;
394
+ private ownsRegisteredSession;
395
+ /** Forget passive session disappearance, preserving unacknowledged stop receipts. */
396
+ forgetStopped(): void;
397
+ reconcile(): Promise<void>;
398
+ pendingCleanup(): number;
399
+ archive(owner: string): void;
400
+ private schedule;
401
+ dispose(): Promise<void>;
402
+ }
403
+ //#endregion
404
+ //#region src/tools.d.ts
405
+ /** Plugin configuration resolved from the Schemastery schema in index.ts. */
406
+ interface PluginConfig {
407
+ bskPath: string;
408
+ /** Optional durable start journal directory (isolated by host/profile when configured). */
409
+ sessionStateDirectory?: string;
410
+ defaultTimeoutMs: number;
411
+ maxSessions: number;
412
+ observationEnabled: boolean;
413
+ thumbnailIntervalMs: number;
414
+ idleIntervalMs: number;
415
+ /**
416
+ * Lazy tool-schema injection: when true (default), the bsk_* tools are
417
+ * registered only after the `browser-skill` skill has been successfully
418
+ * invoked (skill catalog entry advertises alone until then); when false,
419
+ * the suite is registered at apply time (legacy always-on behavior).
420
+ */
421
+ lazyTools: boolean;
422
+ }
423
+ interface ToolDeps {
424
+ ctx: Context;
425
+ runner: BskRunner;
426
+ registry: SessionRegistry;
427
+ config: PluginConfig;
428
+ observation: ObservationService;
429
+ /** Per-session FIFO: the daemon rejects a second command while one is unfinished. */
430
+ queue: KeyedExecutor;
431
+ starts?: SessionStarts;
432
+ }
433
+ //#endregion
434
+ //#region src/archive-cleanup.d.ts
435
+ /**
436
+ * The DSH session ids that own a tool call's browser sessions: the calling
437
+ * agent's own session first, followed by ancestors along the seed lineage. Empty
438
+ * when the call carried no agent identity (those sessions outlive any
439
+ * archive cleanup by design — nothing can name their owner).
440
+ */
441
+ declare function ownerSessionIds(ctx: Context, agentId: string | undefined): string[];
442
+ /**
443
+ * Watch conversation archival and stop the bsk sessions it opened. Returns
444
+ * the disposer (plugin unload). In compositions without the workspace
445
+ * domain (headless), the event simply never fires — and a context without
446
+ * the events mixin degrades to a no-op like the other optional seams.
447
+ */
448
+ declare function armArchiveCleanup(ctx: Context, starts: Pick<SessionStarts, "archive">): () => void;
449
+ //#endregion
450
+ //#region src/browser-tools.d.ts
451
+ /** Register all six tools or roll back this attempt; never remove foreign entries. */
452
+ declare function registerBrowserTools(deps: ToolDeps): () => void;
453
+ //#endregion
454
+ //#region src/observation-http.d.ts
455
+ /**
456
+ * Register the observation routes. No-op (with a console note) when the
457
+ * composition has no web server.
458
+ * @returns disposer removing the routes.
459
+ */
460
+ declare function registerObservationRoutes(ctx: Context, observation: ObservationService, lifecycle: Pick<SessionStarts, "stop">): () => void;
461
+ //#endregion
462
+ //#region src/index.d.ts
463
+ declare const name = "@caiqy/browser-skill-dsh-plugin";
464
+ declare const inject: string[];
465
+ /** Runtime configuration schema (validated and defaulted by Cordis). */
466
+ declare const Config: Schema<Schemastery.ObjectS<{
467
+ bskPath: Schema<string, string>;
468
+ sessionStateDirectory: Schema<string, string>;
469
+ defaultTimeoutMs: Schema<number, number>;
470
+ maxSessions: Schema<number, number>;
471
+ observationEnabled: Schema<boolean, boolean>;
472
+ thumbnailIntervalMs: Schema<number, number>;
473
+ idleIntervalMs: Schema<number, number>;
474
+ lazyTools: Schema<boolean, boolean>;
475
+ }>, Schemastery.ObjectT<{
476
+ bskPath: Schema<string, string>;
477
+ sessionStateDirectory: Schema<string, string>;
478
+ defaultTimeoutMs: Schema<number, number>;
479
+ maxSessions: Schema<number, number>;
480
+ observationEnabled: Schema<boolean, boolean>;
481
+ thumbnailIntervalMs: Schema<number, number>;
482
+ idleIntervalMs: Schema<number, number>;
483
+ lazyTools: Schema<boolean, boolean>;
484
+ }>>;
485
+ type Config = PluginConfig;
486
+ /** Test seams: swap the process runner (unit tests never spawn a real bsk). */
487
+ interface ApplyOptions {
488
+ runnerFactory?: (bskPath: string) => BskRunner;
489
+ startJournal?: StartJournal;
490
+ }
491
+ declare function apply(ctx: Context, config?: Partial<PluginConfig>, options?: ApplyOptions): void;
492
+ //#endregion
493
+ export { ApplyOptions, BskError, type BskRunResult, type BskRunner, Config, KeyedExecutor, type ObservationEvent, type ObservationOptions, ObservationService, type PluginConfig, type SessionObservation, SessionRegistry, type SpawnImpl, type ToolDeps, apply, armArchiveCleanup, createBskRunner, inject, name, ownerSessionIds, registerBrowserTools, registerObservationRoutes };