@crouton-kit/crouter 0.3.56 → 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.
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +20 -5
- package/dist/clients/attach/attach-cmd.js +2 -2
- package/dist/clients/attach/chat-view.js +5 -0
- package/dist/clients/web/__tests__/push-engine.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-engine.test.js +302 -0
- package/dist/clients/web/__tests__/push-registry.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-registry.test.js +177 -0
- package/dist/clients/web/events.d.ts +5 -0
- package/dist/clients/web/events.js +16 -0
- package/dist/clients/web/push-engine.d.ts +108 -0
- package/dist/clients/web/push-engine.js +256 -0
- package/dist/clients/web/push-registry.d.ts +89 -0
- package/dist/clients/web/push-registry.js +378 -0
- package/dist/clients/web/server.js +191 -8
- package/dist/clients/web/web-client/shared/protocol.d.ts +308 -0
- package/dist/clients/web/web-client/shared/protocol.js +8 -0
- package/dist/web-client/assets/{index-rvMEymEb.js → index-BOlkXTJY.js} +18 -18
- package/dist/web-client/assets/index-CllMPSPq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +3 -1
- package/dist/web-client/assets/index-C6rGKvf2.css +0 -2
|
@@ -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
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 {};
|