@crouton-kit/crouter 0.3.57 → 0.3.58

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.
@@ -15,6 +15,13 @@
15
15
  // and relay frames VERBATIM both directions. The relay
16
16
  // adds NOTHING — the browser is the SAME protocol peer
17
17
  // as `crtr surface attach`.
18
+ // • Web push (§push) → GET /__crtr/push/vapid-public-key, POST/DELETE
19
+ // /__crtr/push/subscribe. A server-side diff engine
20
+ // (push-engine.ts) watches the SAME SSE invalidations
21
+ // above and sends web-push to a persisted, auth-scoped
22
+ // registry (push-registry.ts) — see D8 in the mobile-web
23
+ // design. Behind the identical auth boundary as the rest
24
+ // of this server.
18
25
  //
19
26
  // §0 ONE-WRITER INVARIANT: the WS relay is ONLY a socket relay. It NEVER calls
20
27
  // reviveNode, NEVER spawns `pi --session`, NEVER touches SessionManager, and
@@ -29,10 +36,12 @@
29
36
  // clients) — the broker fans out natively, so there is no bridge-side fan-out.
30
37
  import { createServer as createHttpServer } from 'node:http';
31
38
  import { createConnection, isIP } from 'node:net';
39
+ import { createHash } from 'node:crypto';
32
40
  import { createReadStream, existsSync, statSync } from 'node:fs';
33
41
  import { fileURLToPath } from 'node:url';
34
42
  import { dirname, extname, join, normalize, resolve } from 'node:path';
35
43
  import { WebSocketServer } from 'ws';
44
+ import webpush from 'web-push';
36
45
  import { viewSocketPath } from '../../core/canvas/paths.js';
37
46
  import { getNode } from '../../core/canvas/index.js';
38
47
  import { BROKER_READ_CAPS, CLIENT_READ_CAPS, FrameDecoder, FrameOverflowError, } from '../../core/runtime/broker-protocol.js';
@@ -41,9 +50,13 @@ import { clearFault, recordFault } from '../../core/runtime/fault.js';
41
50
  import { createLocalTransport } from '../../core/view/transport-local.js';
42
51
  import { createBurstCoalescingTransport } from '../../core/view/transport-cache.js';
43
52
  import { runSourceRequest } from '../../core/view/bridge.js';
53
+ import { canvasSnapshotLeaf } from '../../commands/canvas-snapshot.js';
54
+ import { humanList } from '../../commands/human/queue.js';
44
55
  import { startEventHub } from './events.js';
45
56
  import { createDevServer } from './dev-server.js';
46
57
  import { SOURCE_CACHE_TTL_MS, isCoalescableSourceRequest } from './source-cache.js';
58
+ import { PushLastSeenStore, PushRegistryStore, PushRequestError, loadOrCreateVapidKeypair, parsePushDeleteBody, parsePushSubscriptionBody, pushBodyMaxBytes, } from './push-registry.js';
59
+ import { PushDiffEngine } from './push-engine.js';
47
60
  const HERE = dirname(fileURLToPath(import.meta.url));
48
61
  /** Candidate dirs to serve the shell SPA bundle from, in priority order: an
49
62
  * explicit override, the compiled-module-relative `dist/web-client/` (what
@@ -158,6 +171,32 @@ function readBody(req) {
158
171
  req.on('error', rej);
159
172
  });
160
173
  }
174
+ /** Like `readBody`, but aborts the moment the accumulated body exceeds
175
+ * `maxBytes` — so a push endpoint never buffers arbitrary attacker memory
176
+ * before validation rejects it. Rejects with PushRequestError(413), which the
177
+ * push error handler maps to a 413 response. */
178
+ function readBodyCapped(req, maxBytes) {
179
+ return new Promise((resolve, reject) => {
180
+ const chunks = [];
181
+ let total = 0;
182
+ req.on('data', (c) => {
183
+ total += c.length;
184
+ if (total > maxBytes) {
185
+ reject(new PushRequestError(413, 'push subscription body is too large'));
186
+ req.destroy();
187
+ return;
188
+ }
189
+ chunks.push(c);
190
+ });
191
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
192
+ req.on('error', reject);
193
+ });
194
+ }
195
+ /** Derive the persisted auth scope from a bearer token: a short, stable hash so
196
+ * the raw secret never touches disk. */
197
+ function scopeForToken(secret) {
198
+ return `token:${createHash('sha256').update(secret).digest('base64url').slice(0, 16)}`;
199
+ }
161
200
  /** Serve a static file from the client dir, with SPA fallback to index.html for
162
201
  * any unmatched (non-asset) GET — so `/` and `/node/<id>` both boot the app
163
202
  * shell. Returns true if it wrote a response. */
@@ -385,13 +424,92 @@ export async function startWebServer(opts) {
385
424
  return true;
386
425
  return allowedOrigins.has(origin);
387
426
  };
388
- const requestAllowed = (req) => {
389
- if (requestTokenMatches(req, token))
390
- return true;
427
+ const authorizeRequest = (req) => {
428
+ if (token !== undefined && requestTokenMatches(req, token)) {
429
+ return { allowed: true, scope: scopeForToken(token) };
430
+ }
391
431
  if (tokenRequired)
392
- return false;
393
- return originAllowed(req.headers.origin);
432
+ return { allowed: false };
433
+ if (!originAllowed(req.headers.origin))
434
+ return { allowed: false };
435
+ return { allowed: true, scope: 'local' };
394
436
  };
437
+ // The set of auth scopes this server actually authorizes — the push diff
438
+ // engine sends ONLY to subscriptions whose authScope is in this set (design
439
+ // line 252: identical boundary to /__crtr/source). Mirrors authorizeRequest:
440
+ // a token authorizes scopeForToken(token); a tokenless request is authorized
441
+ // only when !tokenRequired, as scope 'local'. A record left by a rotated/old
442
+ // token has a different digest and is therefore excluded.
443
+ const targetScopes = new Set();
444
+ if (token !== undefined)
445
+ targetScopes.add(scopeForToken(token));
446
+ if (!tokenRequired)
447
+ targetScopes.add('local');
448
+ // §push — the server-side web-push registry + diff engine (design D8). One
449
+ // persisted, auth-scoped subscription registry; one locally-generated VAPID
450
+ // identity signing every send; one diff engine that re-reads canvas+decks on
451
+ // the SAME SSE invalidations the browser clients get (via eventHub.subscribe,
452
+ // no HTTP self-loop) and pushes 'done'/'needs-you' notifications.
453
+ const pushRegistry = new PushRegistryStore();
454
+ const pushLastSeen = new PushLastSeenStore();
455
+ const vapid = loadOrCreateVapidKeypair();
456
+ webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey);
457
+ // Page through every pending human ask, keeping only entries with the deck
458
+ // provenance (job + conversation) the payload/dedupe logic needs.
459
+ const collectPendingAsks = async () => {
460
+ const out = [];
461
+ let cursor;
462
+ for (let page = 0; page < 50; page++) {
463
+ const result = (await humanList.run({
464
+ limit: 100,
465
+ ...(cursor !== undefined ? { cursor } : {}),
466
+ }));
467
+ for (const item of result.items) {
468
+ if (item.job_id === undefined || item.conversation_id === undefined)
469
+ continue;
470
+ out.push({
471
+ id: item.id,
472
+ jobId: item.job_id,
473
+ title: item.title ?? item.job_id,
474
+ conversationId: item.conversation_id,
475
+ conversationTitle: item.conversation_title ?? item.conversation_id,
476
+ blockedSince: item.blocked_since,
477
+ });
478
+ }
479
+ if (result.next_cursor === null)
480
+ break;
481
+ cursor = result.next_cursor;
482
+ }
483
+ return out;
484
+ };
485
+ const pushEngine = new PushDiffEngine({
486
+ registry: pushRegistry,
487
+ lastSeen: pushLastSeen,
488
+ readCanvasNodes: async () => {
489
+ const snap = (await canvasSnapshotLeaf.run({}));
490
+ return snap.nodes;
491
+ },
492
+ readPendingAsks: collectPendingAsks,
493
+ isTargetScope: (s) => targetScopes.has(s),
494
+ sendPush: async (record, payload) => {
495
+ try {
496
+ await webpush.sendNotification({ endpoint: record.endpoint, keys: record.keys }, JSON.stringify(payload));
497
+ return 'sent';
498
+ }
499
+ catch (err) {
500
+ const statusCode = err.statusCode;
501
+ return statusCode === 404 || statusCode === 410 ? 'gone' : 'error';
502
+ }
503
+ },
504
+ });
505
+ // A restart is not an attention event — record current state, send nothing.
506
+ await pushEngine.seed();
507
+ const unsubscribePush = eventHub.subscribe((kind) => {
508
+ if (kind === 'nodes')
509
+ void pushEngine.onNodesInvalidation();
510
+ else if (kind === 'inbox')
511
+ void pushEngine.onInboxInvalidation();
512
+ });
395
513
  // Assigned before listen() when --dev; the request handler closes over it.
396
514
  let vite;
397
515
  const httpServer = createHttpServer((req, res) => {
@@ -408,7 +526,7 @@ export async function startWebServer(opts) {
408
526
  res.end('method not allowed\n');
409
527
  return;
410
528
  }
411
- if (!requestAllowed(req)) {
529
+ if (!authorizeRequest(req).allowed) {
412
530
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
413
531
  res.end('forbidden\n');
414
532
  return;
@@ -434,7 +552,7 @@ export async function startWebServer(opts) {
434
552
  res.end('method not allowed\n');
435
553
  return;
436
554
  }
437
- if (!requestAllowed(req)) {
555
+ if (!authorizeRequest(req).allowed) {
438
556
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
439
557
  res.end('forbidden\n');
440
558
  return;
@@ -442,6 +560,70 @@ export async function startWebServer(opts) {
442
560
  eventHub.addClient(res);
443
561
  return;
444
562
  }
563
+ // §push — the VAPID public key a browser needs to subscribe. Read-only,
564
+ // non-secret, but behind the same auth boundary as everything else so a
565
+ // foreign page can't even enumerate the surface.
566
+ if (pathname === '/__crtr/push/vapid-public-key') {
567
+ if (req.method !== 'GET') {
568
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
569
+ res.end('method not allowed\n');
570
+ return;
571
+ }
572
+ if (!authorizeRequest(req).allowed) {
573
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
574
+ res.end('forbidden\n');
575
+ return;
576
+ }
577
+ res.writeHead(200, { 'content-type': 'application/json' });
578
+ res.end(JSON.stringify({ publicKey: vapid.publicKey }));
579
+ return;
580
+ }
581
+ // §push — subscribe (POST) / unsubscribe (DELETE). Validation lives in
582
+ // parsePush*Body, which rejects oversized/malformed/private/http/file
583
+ // shapes BEFORE any persistence, throwing PushRequestError with a status.
584
+ if (pathname === '/__crtr/push/subscribe') {
585
+ const auth = authorizeRequest(req);
586
+ if (!auth.allowed) {
587
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
588
+ res.end('forbidden\n');
589
+ return;
590
+ }
591
+ const handlePushError = (e) => {
592
+ if (e instanceof PushRequestError) {
593
+ res.writeHead(e.statusCode, { 'content-type': 'application/json' });
594
+ res.end(JSON.stringify({ ok: false, error: e.message }));
595
+ return;
596
+ }
597
+ res.writeHead(500, { 'content-type': 'application/json' });
598
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
599
+ };
600
+ if (req.method === 'POST') {
601
+ void readBodyCapped(req, pushBodyMaxBytes())
602
+ .then((body) => {
603
+ const record = parsePushSubscriptionBody(body, auth.scope);
604
+ const stored = pushRegistry.upsert(record);
605
+ res.writeHead(200, { 'content-type': 'application/json' });
606
+ res.end(JSON.stringify({ ok: true, id: stored.id, authScope: stored.authScope }));
607
+ })
608
+ .catch(handlePushError);
609
+ return;
610
+ }
611
+ if (req.method === 'DELETE') {
612
+ void readBodyCapped(req, pushBodyMaxBytes())
613
+ .then((body) => {
614
+ const { endpoint } = parsePushDeleteBody(body);
615
+ // Scoped: a caller may only delete a subscription it owns.
616
+ const deleted = pushRegistry.deleteByEndpoint(endpoint, auth.scope);
617
+ res.writeHead(200, { 'content-type': 'application/json' });
618
+ res.end(JSON.stringify({ ok: true, deleted }));
619
+ })
620
+ .catch(handlePushError);
621
+ return;
622
+ }
623
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'POST, DELETE' });
624
+ res.end('method not allowed\n');
625
+ return;
626
+ }
445
627
  // --dev: Vite middleware owns all remaining asset/HTML serving (incl. its
446
628
  // own SPA fallback). It is mounted AFTER the bridge + SSE checks above, so
447
629
  // those never fall through to Vite.
@@ -490,7 +672,7 @@ export async function startWebServer(opts) {
490
672
  socket.destroy();
491
673
  return;
492
674
  }
493
- if (!requestAllowed(req)) {
675
+ if (!authorizeRequest(req).allowed) {
494
676
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
495
677
  socket.destroy();
496
678
  return;
@@ -533,6 +715,7 @@ export async function startWebServer(opts) {
533
715
  `http://localhost:${port}`,
534
716
  ]);
535
717
  const close = () => new Promise((done) => {
718
+ unsubscribePush();
536
719
  eventHub.close();
537
720
  void vite?.close().catch(() => {
538
721
  /* best-effort */
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Shared wire types for the crouter web client/server split.
3
+ *
4
+ * The SPA imports the pi payload types it needs from this module — never from
5
+ * broker-protocol internals. The type-only re-exports below keep the web
6
+ * package's shared payload shapes in one place.
7
+ */
8
+ export type { UserMessage, AssistantMessage, ToolResultMessage, TextContent, ToolCall, ImageContent, } from '@earendil-works/pi-ai';
9
+ export type { ThinkingLevel } from '@earendil-works/pi-agent-core';
10
+ export type { RpcExtensionUIRequest, RpcExtensionUIResponse } from '@earendil-works/pi-coding-agent';
11
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
12
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
13
+ /**
14
+ * An `AgentMessage` enriched with a client-side origin tag.
15
+ * Only `role:'user'` messages can carry `origin:'inbox'`; all others leave
16
+ * `origin` undefined. `'human'` is reserved for future explicit human-origin
17
+ * tagging (currently unset — absence means human by default).
18
+ *
19
+ * Defined as an intersection (not `interface extends`) because `AgentMessage`
20
+ * is a union type and TypeScript does not allow extending unions.
21
+ */
22
+ export type FoldedMessage = AgentMessage & {
23
+ /** Set to `'inbox'` when this user message is recognized as injected by the
24
+ * canvas-inbox-watcher extension (a coalesced inbox digest). */
25
+ origin?: 'inbox' | 'human';
26
+ };
27
+ /** base session vs orchestrator. */
28
+ export type NodeMode = 'base' | 'orchestrator';
29
+ /** terminal (reaps when done) vs resident (root) node. */
30
+ export type NodeLifecycle = 'terminal' | 'resident';
31
+ /** Canvas lifecycle status. */
32
+ export type NodeLifeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
33
+ /** broker-hosted (enterable) vs tmux-pane (shown, non-enterable). */
34
+ export type HostKind = 'broker' | 'tmux';
35
+ /** One row from `crtr canvas snapshot --json` — node identity + runtime, no chrome. */
36
+ export interface NodeSummary {
37
+ node_id: string;
38
+ name: string;
39
+ /** Node kind (developer/explore/…) — open set. */
40
+ kind: string;
41
+ mode: NodeMode;
42
+ lifecycle: NodeLifecycle;
43
+ status: NodeLifeStatus;
44
+ cwd: string;
45
+ /** Spine parent node id, or null for a root. */
46
+ parent: string | null;
47
+ /** ISO-8601 creation timestamp. */
48
+ created: string;
49
+ host_kind: HostKind;
50
+ /** True iff `host_kind === 'broker'` (the web UI can open a live session). */
51
+ enterable: boolean;
52
+ /** Count of pending human asks (blocked-on-human indicator). */
53
+ attention_count: number;
54
+ /** Canvas cycle count for this node (revive/yield generations). Optional for
55
+ * back-compat with snapshots produced before the field existed. */
56
+ cycles?: number;
57
+ /** ISO-8601 of the node's most recent work (session activity), distinct from
58
+ * `created`. Optional/back-compat; falls back to `created` when absent. */
59
+ last_activity?: string;
60
+ }
61
+ /** input/output token burn, with cache reads where the provider reports them. */
62
+ export interface TokenBurn {
63
+ input: number;
64
+ output: number;
65
+ cache?: number;
66
+ }
67
+ /** Live context-window usage. */
68
+ export interface ContextUsage {
69
+ tokens: number;
70
+ window: number;
71
+ percent: number;
72
+ }
73
+ /** Coarse session stats for chrome (distinct from pi's full `SessionStats`). */
74
+ export interface SessionStatsSummary {
75
+ turns: number;
76
+ user_messages: number;
77
+ assistant_messages: number;
78
+ cost?: number;
79
+ }
80
+ /** Viewer/controller presence for a node. */
81
+ export interface Presence {
82
+ viewers: number;
83
+ controller: string | null;
84
+ }
85
+ /** Git working-tree change counts for the meta strip. */
86
+ export interface GitStatus {
87
+ added: number;
88
+ modified: number;
89
+ deleted: number;
90
+ untracked: number;
91
+ }
92
+ /** `crtr node inspect show --json`'s node payload — `NodeSummary` plus the per-node chrome. */
93
+ export interface NodeDetail extends NodeSummary {
94
+ branch: string | null;
95
+ model: string | null;
96
+ tokens: TokenBurn | null;
97
+ context: ContextUsage | null;
98
+ tool_calls: number | null;
99
+ stats: SessionStatsSummary | null;
100
+ presence: Presence | null;
101
+ /** True while a broker is live; false for a dormant (last-known) view. */
102
+ live: boolean;
103
+ /** Working-tree change counts for the meta strip (null when unavailable). */
104
+ git_status?: GitStatus | null;
105
+ }
106
+ /** A slash command in a node's palette (from the broker `get_commands` reply). */
107
+ export interface Command {
108
+ name: string;
109
+ description: string;
110
+ source: 'builtin' | 'command' | 'template';
111
+ location?: 'user' | 'project' | 'path';
112
+ path?: string;
113
+ argument_hint?: string;
114
+ }
115
+ /** `crtr canvas snapshot --json` body. */
116
+ export interface CanvasSnapshot {
117
+ nodes: NodeSummary[];
118
+ /** ISO-8601. */
119
+ generated_at: string;
120
+ }
121
+ /** `crtr node new --json` success. */
122
+ export interface SpawnResponse {
123
+ node_id: string;
124
+ name: string;
125
+ window?: string;
126
+ session?: string;
127
+ status: string;
128
+ follow_up: string;
129
+ }
130
+ /** `crtr node msg --json` success. */
131
+ export interface MessageResponse {
132
+ mode: string;
133
+ armed: boolean;
134
+ target: string;
135
+ delivered?: boolean;
136
+ revived?: boolean;
137
+ guidance: string;
138
+ }
139
+ /** `crtr node lifecycle revive --json` success. */
140
+ export interface ReviveResponse {
141
+ window: null;
142
+ session: string | null;
143
+ resumed: boolean;
144
+ ready: boolean;
145
+ }
146
+ /** `crtr node lifecycle close --json` success. */
147
+ export interface CloseResponse {
148
+ closed: boolean;
149
+ node_id: string;
150
+ count: number;
151
+ closed_ids: string[];
152
+ spared: string[];
153
+ }
154
+ /** `crtr node new` body. Always headless; `root` toggles a resident node. */
155
+ export interface SpawnRequest {
156
+ prompt: string;
157
+ kind: string;
158
+ mode?: NodeMode;
159
+ root?: boolean;
160
+ cwd?: string;
161
+ name?: string;
162
+ model?: string;
163
+ parent?: string;
164
+ }
165
+ /** `crtr node msg` body. */
166
+ export interface MessageRequest {
167
+ body: string;
168
+ tier?: string;
169
+ }
170
+ /** `crtr node lifecycle revive` body. */
171
+ export interface ReviveRequest {
172
+ fresh?: boolean;
173
+ }
174
+ /**
175
+ * The five resolution flows (design §5.2). humanloop's `InteractionKind` also
176
+ * has `review`; the deck loader normalizes it (and any unknown/absent kind)
177
+ * onto one of these five so a deck always renders through a known flow.
178
+ */
179
+ export type DeckKind = 'notify' | 'validation' | 'decision' | 'context' | 'error';
180
+ /** One option of a decision/validation interaction (humanloop InteractionOption). */
181
+ export interface DeckOption {
182
+ id: string;
183
+ label: string;
184
+ /** The option's consequence/explanation (humanloop `description`). */
185
+ description?: string;
186
+ }
187
+ /** One interaction within a deck (humanloop Interaction, normalized for the web). */
188
+ export interface DeckInteraction {
189
+ id: string;
190
+ title: string;
191
+ subtitle?: string;
192
+ /** Markdown body (bodyPath already inlined by `crtr human deck`). */
193
+ body?: string;
194
+ kind: DeckKind;
195
+ options: DeckOption[];
196
+ multiSelect: boolean;
197
+ allowFreetext: boolean;
198
+ freetextLabel?: string;
199
+ }
200
+ /**
201
+ * A pending ask, ranked in the inbox list (design §5.2). Provenance carries
202
+ * BOTH the conversation-facing summary (rendered by default, never the node
203
+ * id alone) and the raw asking node, shown alongside it.
204
+ */
205
+ export interface DeckSummary {
206
+ /** Opaque, stable inbox-entry id (base64url of the interaction dir). */
207
+ id: string;
208
+ /** The interaction job id (basename of the interaction dir). */
209
+ job_id: string;
210
+ /** The first interaction's kind — the glyph + flow for the row. */
211
+ kind: DeckKind;
212
+ title: string;
213
+ subtitle?: string;
214
+ /** ISO-8601 — when the ask started blocking (drives the wait duration + rank). */
215
+ blocked_since: string;
216
+ /** The conversation (spine root) this ask belongs to. */
217
+ conversation_id: string;
218
+ conversation_title: string;
219
+ /** The node that raised the ask. */
220
+ asking_node_id: string;
221
+ asking_node_name: string;
222
+ /** The asking node's cwd — sub-DAG scoping. */
223
+ cwd: string;
224
+ /** How many interactions the deck holds (a multi-question deck). */
225
+ interaction_count: number;
226
+ }
227
+ /** The full deck for a resolution flow (`crtr human deck <id> --json`). */
228
+ export interface DeckDetail extends DeckSummary {
229
+ interactions: DeckInteraction[];
230
+ }
231
+ /** One interaction's answer (humanloop InteractionResponse). */
232
+ export interface DeckAnswer {
233
+ id: string;
234
+ selectedOptionId?: string;
235
+ selectedOptionIds?: string[];
236
+ freetext?: string;
237
+ optionComments?: Record<string, string>;
238
+ }
239
+ /** `crtr human resolve <id> --json` body. */
240
+ export interface ResolveDeckRequest {
241
+ responses: DeckAnswer[];
242
+ }
243
+ /** `crtr human resolve <id> --json` success. */
244
+ export interface ResolveDeckResponse {
245
+ resolved: true;
246
+ job_id: string;
247
+ delivered: boolean;
248
+ }
249
+ /**
250
+ * Engine state carried in `snapshot.state` — the broker snapshot's `state`
251
+ * (mirrors `BrokerSnapshot.state`), reconstructed here in pi/primitive types so
252
+ * the SPA never imports the broker-protocol `BrokerSnapshot`. The SPA's
253
+ * streaming/idle indicator reads `isStreaming` (spec C.10).
254
+ */
255
+ export interface SessionState {
256
+ sessionId: string;
257
+ sessionFile: string | null;
258
+ model: string | null;
259
+ isStreaming: boolean;
260
+ thinkingLevel: ThinkingLevel;
261
+ steeringMode: 'all' | 'one-at-a-time';
262
+ followUpMode: 'all' | 'one-at-a-time';
263
+ sessionName: string | null;
264
+ autoCompactionEnabled: boolean;
265
+ pendingMessageCount: number;
266
+ }
267
+ /** Web role of one browser tab's session connection. */
268
+ export type WebRole = 'observer' | 'controller';
269
+ /** Upstream broker connectivity, surfaced to the tab (spec §6.2). */
270
+ export type BrokerStatus = 'connected' | 'reconnecting' | 'down' | 'revived';
271
+ /** Response payload to an extension dialog (`RpcExtensionUIResponse` minus id/type). */
272
+ export type DialogResponseValue = {
273
+ value: string;
274
+ } | {
275
+ confirmed: boolean;
276
+ } | {
277
+ cancelled: true;
278
+ };
279
+ /**
280
+ * A typed content block within a view tab. Discriminated union of three
281
+ * block kinds: inline/sourced markdown, a KPI grid, and a bar-list chart.
282
+ */
283
+ export type ViewBlock = {
284
+ kind: 'markdown';
285
+ source: {
286
+ node_id: string;
287
+ path: string;
288
+ } | {
289
+ inline: string;
290
+ };
291
+ } | {
292
+ kind: 'kpis';
293
+ items: {
294
+ label: string;
295
+ value: string;
296
+ unit?: string;
297
+ sub?: string;
298
+ }[];
299
+ } | {
300
+ kind: 'barlist';
301
+ title: string;
302
+ rows: {
303
+ label: string;
304
+ value: number;
305
+ max?: number;
306
+ note?: string;
307
+ }[];
308
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared wire types for the crouter web client/server split.
3
+ *
4
+ * The SPA imports the pi payload types it needs from this module — never from
5
+ * broker-protocol internals. The type-only re-exports below keep the web
6
+ * package's shared payload shapes in one place.
7
+ */
8
+ export {};