pikiloom 0.4.21 → 0.4.22

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.
Files changed (41) hide show
  1. package/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-G0uiUjBk.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-BQvV5ok2.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-BGacUCRb.js} +1 -1
  4. package/dashboard/dist/assets/ExtensionsTab-Bd2rjOVu.js +1 -0
  5. package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-BD-ZwAOb.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-BwmINu44.js} +1 -1
  7. package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-ncL7g7fW.js} +1 -1
  8. package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D7Iz4BLf.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-zxiA8Pfd.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-DZjEZmVB.js} +1 -1
  11. package/dashboard/dist/assets/{index-7pyJSn4B.js → index-CTHVuMnd.js} +17 -17
  12. package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
  13. package/dashboard/dist/assets/index-DEvh1R9o.js +3 -0
  14. package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-D5VHAaFg.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/index.js +1 -1
  17. package/dist/agent/skill-installer.js +95 -0
  18. package/dist/catalog/skill-repos.js +12 -0
  19. package/dist/cli/main.js +31 -1
  20. package/dist/core/secrets/ref.js +8 -0
  21. package/dist/core/secrets/store.js +31 -11
  22. package/dist/dashboard/routes/extensions.js +137 -24
  23. package/dist/dashboard/server.js +27 -64
  24. package/dist/pikichannel/adapter-pikiloom.js +220 -0
  25. package/dist/pikichannel/code.js +35 -0
  26. package/dist/pikichannel/codec.js +50 -0
  27. package/dist/pikichannel/host.js +252 -0
  28. package/dist/pikichannel/protocol.js +105 -0
  29. package/dist/pikichannel/rendezvous-broker.js +114 -0
  30. package/dist/pikichannel/rendezvous-host.js +138 -0
  31. package/dist/pikichannel/server.js +284 -0
  32. package/dist/pikichannel/transport.js +49 -0
  33. package/dist/pikichannel/transports/webrtc-host.js +61 -0
  34. package/dist/pikichannel/transports/webrtc-shared.js +132 -0
  35. package/dist/pikichannel/transports/websocket-host.js +78 -0
  36. package/dist/pikichannel/web/demo.html +246 -0
  37. package/dist/pikichannel/web/sdk.js +361 -0
  38. package/package.json +4 -2
  39. package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
  40. package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
  41. package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
@@ -0,0 +1,132 @@
1
+ /**
2
+ * pikichannel/transports/webrtc-shared.ts — shared werift answerer + connection.
3
+ *
4
+ * The host is always the WebRTC *answerer* (the browser/SDK creates the offer +
5
+ * datachannel). The same answerer logic drives two signaling paths:
6
+ * - direct (webrtc-host.ts): SDP/ICE over a same-origin `/pikichannel/signal`
7
+ * WebSocket — for clients that can already reach the host.
8
+ * - rendezvous (rendezvous.ts): SDP/ICE relayed through a public broker both
9
+ * peers dial OUTBOUND — the NAT-traversal path.
10
+ *
11
+ * Once the datachannel opens the bytes are pure P2P (DTLS-encrypted); signaling
12
+ * only brokers the handshake. `getIceServers()` is the STUN/TURN config hook:
13
+ * STUN by default, TURN added via PIKICHANNEL_ICE_SERVERS for symmetric NAT.
14
+ */
15
+ import { RTCPeerConnection } from 'werift';
16
+ import { BaseConnection } from '../transport.js';
17
+ let connCounter = 0;
18
+ /**
19
+ * ICE servers for hole-punching. STUN alone covers most NATs; add a TURN relay
20
+ * for symmetric-NAT reliability via:
21
+ * PIKICHANNEL_ICE_SERVERS='[{"urls":"stun:stun.l.google.com:19302"},
22
+ * {"urls":"turn:turn.example.com:3478","username":"u","credential":"p"}]'
23
+ */
24
+ export function getIceServers() {
25
+ const raw = process.env.PIKICHANNEL_ICE_SERVERS;
26
+ if (raw) {
27
+ try {
28
+ const v = JSON.parse(raw);
29
+ if (Array.isArray(v) && v.length)
30
+ return v;
31
+ }
32
+ catch { /* fall through to default */ }
33
+ }
34
+ return [{ urls: 'stun:stun.l.google.com:19302' }];
35
+ }
36
+ function coerceFrame(data) {
37
+ if (typeof data === 'string')
38
+ return data;
39
+ if (Buffer.isBuffer(data))
40
+ return data.toString('utf8');
41
+ try {
42
+ return Buffer.from(new Uint8Array(data)).toString('utf8');
43
+ }
44
+ catch {
45
+ return String(data);
46
+ }
47
+ }
48
+ /** A pikichannel connection riding an SCTP datachannel. */
49
+ export class RtcConnection extends BaseConnection {
50
+ channel;
51
+ pc;
52
+ id;
53
+ kind = 'webrtc';
54
+ constructor(channel, pc, remote) {
55
+ super();
56
+ this.channel = channel;
57
+ this.pc = pc;
58
+ this.id = `rtc-${++connCounter}`;
59
+ this.remote = remote || 'webrtc-peer';
60
+ channel.onmessage = (ev) => this.emitMessage(coerceFrame(ev?.data));
61
+ channel.onclose = () => this.close();
62
+ pc.connectionStateChange.subscribe((state) => {
63
+ if (state === 'failed' || state === 'closed' || state === 'disconnected')
64
+ this.close();
65
+ });
66
+ }
67
+ send(frame) {
68
+ if (this.channel.readyState === 'open') {
69
+ try {
70
+ this.channel.send(frame);
71
+ }
72
+ catch { /* drop on closing channel */ }
73
+ }
74
+ }
75
+ isOpen() { return this.channel.readyState === 'open'; }
76
+ close() {
77
+ try {
78
+ this.channel.close();
79
+ }
80
+ catch { /* ignore */ }
81
+ try {
82
+ this.pc.close();
83
+ }
84
+ catch { /* ignore */ }
85
+ this.emitClose();
86
+ }
87
+ }
88
+ /**
89
+ * Build a werift answerer. The caller wires `sendSignal` to whatever signaling
90
+ * channel it has (a WS, or the rendezvous), feeds inbound signals to `onSignal`,
91
+ * and receives the live {@link ChannelConnection} via `onConnection` once the
92
+ * datachannel opens. Trickle ICE: the answer is sent immediately, candidates
93
+ * follow as they are gathered.
94
+ */
95
+ export function createAnswerer(opts) {
96
+ const pc = new RTCPeerConnection({ iceServers: getIceServers() });
97
+ let adopted = false;
98
+ pc.onDataChannel.subscribe((channel) => {
99
+ adopted = true;
100
+ opts.onConnection(new RtcConnection(channel, pc, opts.remote));
101
+ });
102
+ pc.onIceCandidate.subscribe((candidate) => {
103
+ if (candidate)
104
+ opts.sendSignal({ kind: 'candidate', candidate: candidate.toJSON ? candidate.toJSON() : candidate });
105
+ });
106
+ return {
107
+ async onSignal(data) {
108
+ try {
109
+ if (data.kind === 'offer' && typeof data.sdp === 'string') {
110
+ await pc.setRemoteDescription({ type: 'offer', sdp: data.sdp });
111
+ const answer = await pc.createAnswer();
112
+ await pc.setLocalDescription(answer);
113
+ const local = pc.localDescription;
114
+ if (local)
115
+ opts.sendSignal({ kind: 'answer', type: local.type, sdp: local.sdp });
116
+ }
117
+ else if (data.kind === 'candidate' && data.candidate) {
118
+ await pc.addIceCandidate(data.candidate);
119
+ }
120
+ }
121
+ catch (err) {
122
+ opts.log?.(`answerer signal error: ${err?.message || err}`);
123
+ opts.sendSignal({ kind: 'error', message: err?.message || 'signaling failed' });
124
+ }
125
+ },
126
+ isAdopted: () => adopted,
127
+ close() { try {
128
+ pc.close();
129
+ }
130
+ catch { /* ignore */ } },
131
+ };
132
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * pikichannel/transports/websocket-host.ts — the WebSocket L1 binding (host).
3
+ *
4
+ * The baseline transport: protocol frames ride a raw WebSocket. Same-machine /
5
+ * LAN clients (the Dashboard case) want exactly this — there is no NAT to
6
+ * traverse, so a TCP WebSocket is the optimal path. It is the control against
7
+ * which the WebRTC binding is compared.
8
+ *
9
+ * The server wiring owns upgrade routing and feeds matching upgrades to
10
+ * `handleUpgrade`; this class only manages the noServer WebSocketServer and
11
+ * wraps each socket as a {@link ChannelConnection}.
12
+ */
13
+ import { WebSocketServer } from 'ws';
14
+ import { BaseConnection } from '../transport.js';
15
+ const WS_KEEPALIVE_MS = 25_000;
16
+ let connCounter = 0;
17
+ class WsConnection extends BaseConnection {
18
+ ws;
19
+ id;
20
+ kind = 'websocket';
21
+ constructor(ws, remote) {
22
+ super();
23
+ this.ws = ws;
24
+ this.id = `ws-${++connCounter}`;
25
+ this.remote = remote;
26
+ ws.on('message', (raw) => {
27
+ const data = raw;
28
+ const frame = typeof data === 'string'
29
+ ? data
30
+ : Buffer.isBuffer(data)
31
+ ? data.toString('utf8')
32
+ : Array.isArray(data)
33
+ ? Buffer.concat(data).toString('utf8')
34
+ : Buffer.from(data).toString('utf8');
35
+ this.emitMessage(frame);
36
+ });
37
+ ws.on('close', () => this.emitClose());
38
+ ws.on('error', () => this.emitClose());
39
+ }
40
+ send(frame) {
41
+ if (this.ws.readyState === this.ws.OPEN)
42
+ this.ws.send(frame);
43
+ }
44
+ isOpen() {
45
+ return this.ws.readyState === this.ws.OPEN;
46
+ }
47
+ close() {
48
+ try {
49
+ this.ws.close();
50
+ }
51
+ catch { /* ignore */ }
52
+ this.emitClose();
53
+ }
54
+ }
55
+ export class WebSocketTransport {
56
+ kind = 'websocket';
57
+ wss = new WebSocketServer({ noServer: true });
58
+ onConn = null;
59
+ start(onConnection) {
60
+ this.onConn = onConnection;
61
+ this.wss.on('connection', (ws, req) => {
62
+ const remote = req.socket.remoteAddress ? `${req.socket.remoteAddress}:${req.socket.remotePort}` : undefined;
63
+ const keepalive = setInterval(() => {
64
+ if (ws.readyState === ws.OPEN)
65
+ ws.ping();
66
+ }, WS_KEEPALIVE_MS);
67
+ ws.on('close', () => clearInterval(keepalive));
68
+ this.onConn?.(new WsConnection(ws, remote));
69
+ });
70
+ }
71
+ /** Server wiring routes a matching upgrade request here. */
72
+ handleUpgrade(req, socket, head) {
73
+ this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
74
+ }
75
+ stop() {
76
+ this.wss.close();
77
+ }
78
+ }
@@ -0,0 +1,246 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>pikichannel · pluggable transport demo</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0b0f14; --panel: #131922; --panel2: #0e141c; --line: #1f2733;
10
+ --ink: #e6edf3; --dim: #8b97a7; --accent: #29d3c2; --accent2: #3b82f6;
11
+ --warn: #f0883e; --bad: #f85149; --good: #3fb950;
12
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; background: var(--bg); color: var(--ink); height: 100vh; display: flex; flex-direction: column; font-size: 13px; }
16
+ header { padding: 10px 16px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
17
+ .brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
18
+ .brand small { color: var(--dim); font-weight: 400; margin-left: 8px; }
19
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
20
+ .seg button { background: var(--panel); color: var(--dim); border: 0; padding: 6px 12px; cursor: pointer; font: inherit; }
21
+ .seg button.on { background: var(--accent); color: #04121a; font-weight: 700; }
22
+ .btn { background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 6px 14px; cursor: pointer; font: inherit; }
23
+ .btn:hover { border-color: var(--accent); }
24
+ .btn.primary { background: var(--accent2); border-color: var(--accent2); color: #fff; }
25
+ .stats { margin-left: auto; display: flex; gap: 14px; align-items: center; color: var(--dim); flex-wrap: wrap; }
26
+ .stat b { color: var(--ink); font-weight: 600; }
27
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--dim); display: inline-block; }
28
+ .dot.open { background: var(--good); } .dot.connecting { background: var(--warn); } .dot.closed { background: var(--bad); }
29
+ main { flex: 1; display: grid; grid-template-columns: 250px 1fr; min-height: 0; }
30
+ aside { border-right: 1px solid var(--line); overflow-y: auto; background: var(--panel2); }
31
+ .sess { padding: 10px 12px; border-bottom: 1px solid var(--line); cursor: pointer; }
32
+ .sess:hover { background: var(--panel); } .sess.sel { background: var(--panel); border-left: 3px solid var(--accent); }
33
+ .sess .k { color: var(--dim); font-size: 11px; } .sess .t { margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
34
+ .badge { font-size: 10px; padding: 1px 6px; border-radius: 6px; background: var(--line); color: var(--dim); }
35
+ .badge.streaming { background: #10303a; color: var(--accent); } .badge.done { background: #16301c; color: var(--good); } .badge.queued { background: #2a2410; color: var(--warn); }
36
+ section.view { display: flex; flex-direction: column; min-height: 0; }
37
+ .scroll { flex: 1; overflow-y: auto; padding: 16px; }
38
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; }
39
+ .card h4 { margin: 0 0 8px; color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .6px; }
40
+ .meta { display: flex; gap: 14px; color: var(--dim); flex-wrap: wrap; margin-bottom: 10px; }
41
+ .meta b { color: var(--ink); }
42
+ pre { white-space: pre-wrap; word-break: break-word; margin: 0; font-family: inherit; line-height: 1.5; }
43
+ .prompt { color: var(--accent2); }
44
+ .reason { color: var(--dim); } details summary { cursor: pointer; color: var(--dim); }
45
+ .step { padding: 2px 0; } .step.completed { color: var(--good); } .step.inProgress { color: var(--accent); } .step.pending { color: var(--dim); }
46
+ .tool { border-left: 2px solid var(--line); padding: 2px 0 2px 8px; margin: 3px 0; color: var(--dim); }
47
+ .tool b { color: var(--ink); }
48
+ .interaction { border: 1px solid var(--accent); border-radius: 8px; padding: 10px; margin-bottom: 10px; }
49
+ .choice { display: inline-block; margin: 4px 6px 0 0; }
50
+ footer { border-top: 1px solid var(--line); padding: 10px 16px; display: flex; gap: 10px; align-items: flex-end; }
51
+ textarea { flex: 1; background: var(--panel2); color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font: inherit; resize: none; height: 46px; }
52
+ select, input { background: var(--panel2); color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 8px; font: inherit; }
53
+ .empty { color: var(--dim); text-align: center; margin-top: 60px; }
54
+ a { color: var(--accent); }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <header>
59
+ <div class="brand">pikichannel <small>pluggable agent transport — live demo</small></div>
60
+ <div class="seg" id="seg">
61
+ <button data-k="websocket" class="on">WebSocket</button>
62
+ <button data-k="webrtc">WebRTC (P2P)</button>
63
+ </div>
64
+ <input id="endpoint" placeholder="host (blank = this machine)" title="Direct target, e.g. 192.168.1.5:3940 — for a reachable host." style="width:130px" />
65
+ <input id="rdv" placeholder="rendezvous (NAT)" title="Broker ws(s):// URL for NAT traversal, e.g. wss://broker/pikichannel/rendezvous" style="width:140px" />
66
+ <input id="node" placeholder="nodeId (NAT)" title="The host's NodeID to dial through the rendezvous." style="width:110px" />
67
+ <input id="token" placeholder="token (remote)" title="Access token — required for remote/NAT clients. Get it from the local dashboard / GET /pikichannel/pair." style="width:120px" />
68
+ <button class="btn primary" id="connect">Connect</button>
69
+ <button class="btn" id="disconnect">Disconnect</button>
70
+ <div class="stats">
71
+ <span class="stat"><span class="dot" id="dot"></span> <b id="state">idle</b></span>
72
+ <span class="stat">via <b id="via">—</b></span>
73
+ <span class="stat">RTT <b id="rtt">—</b></span>
74
+ <span class="stat">frames <b id="frames">0/0</b></span>
75
+ <span class="stat">bytes <b id="bytes">0/0</b></span>
76
+ <span class="stat">host <b id="host">—</b></span>
77
+ </div>
78
+ </header>
79
+ <main>
80
+ <aside id="sessions"></aside>
81
+ <section class="view">
82
+ <div class="scroll" id="view"><div class="empty">Connect, then send a prompt to start a session.<br/>Flip the transport toggle and reconnect to compare WebSocket vs WebRTC carrying the identical protocol.</div></div>
83
+ <footer>
84
+ <select id="agent" title="agent (blank = host default)">
85
+ <option value="">default agent</option>
86
+ <option value="claude">claude</option>
87
+ <option value="codex">codex</option>
88
+ <option value="gemini">gemini</option>
89
+ <option value="hermes">hermes</option>
90
+ </select>
91
+ <textarea id="composer" placeholder="Type a prompt — Enter to send to a NEW session (or the selected one), Shift+Enter for newline"></textarea>
92
+ <button class="btn primary" id="send">Send</button>
93
+ <button class="btn" id="stop">Stop</button>
94
+ <button class="btn" id="tunnel" title="Call /api/agents on the connected host over the channel (control-plane tunnel)">Tunnel /api/agents</button>
95
+ </footer>
96
+ </section>
97
+ </main>
98
+
99
+ <script type="module">
100
+ import '/pikichannel/sdk.js';
101
+
102
+ const $ = (id) => document.getElementById(id);
103
+ let client = null;
104
+ let kind = 'websocket';
105
+ let selected = null;
106
+
107
+ // transport toggle
108
+ $('seg').addEventListener('click', (e) => {
109
+ const b = e.target.closest('button'); if (!b) return;
110
+ kind = b.dataset.k;
111
+ for (const x of $('seg').children) x.classList.toggle('on', x === b);
112
+ });
113
+
114
+ $('connect').onclick = async () => {
115
+ if (client) client.disconnect();
116
+ client = new window.Pikichannel.Client({
117
+ transport: kind,
118
+ host: $('endpoint').value.trim() || undefined,
119
+ rendezvous: $('rdv').value.trim() || undefined,
120
+ nodeId: $('node').value.trim() || undefined,
121
+ token: $('token').value.trim() || undefined,
122
+ });
123
+ wire(client);
124
+ try { await client.connect(); client.subscribeAll(); }
125
+ catch (err) { /* status reflects failure */ }
126
+ };
127
+ $('disconnect').onclick = () => { if (client) client.disconnect(); };
128
+
129
+ // Control-plane tunnel demo: manage the connected host over the channel.
130
+ $('tunnel').onclick = async () => {
131
+ if (!client || client.state !== 'open') { alert('Not connected'); return; }
132
+ try {
133
+ const res = await client.request('GET', '/api/agents');
134
+ const data = res.json();
135
+ const names = (data.agents || []).map((a) => a.agent + (a.installed ? '' : '(missing)')).join(', ');
136
+ $('view').innerHTML = '';
137
+ $('view').appendChild(card('Control-plane tunnel · GET /api/agents (via channel, no REST exposure)',
138
+ preEl('status ' + res.status + '\nagents: ' + (names || '(none)'))));
139
+ } catch (e) { alert('tunnel failed: ' + e.message); }
140
+ };
141
+
142
+ $('send').onclick = sendPrompt;
143
+ $('composer').addEventListener('keydown', (e) => {
144
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendPrompt(); }
145
+ });
146
+ $('stop').onclick = () => { if (client && selected) client.stop(selected); };
147
+
148
+ function sendPrompt() {
149
+ if (!client || client.state !== 'open') { alert('Not connected'); return; }
150
+ const text = $('composer').value.trim();
151
+ if (!text) return;
152
+ client.prompt({ prompt: text, sessionKey: selected || undefined, agent: $('agent').value || undefined });
153
+ $('composer').value = '';
154
+ }
155
+
156
+ function wire(c) {
157
+ c.on('status', renderStats);
158
+ c.on('welcome', (host) => { $('host').textContent = (host && host.name + ' ' + host.version) || '—'; renderSessions(); });
159
+ c.on('sessions', renderSessions);
160
+ c.on('session', (snap) => { if (!selected) selected = snap.sessionKey; renderSessions(); if (snap.sessionKey === selected) renderView(snap); });
161
+ c.on('accepted', (a) => { selected = a.sessionKey; renderSessions(); });
162
+ c.on('error', (e) => { console.warn('pikichannel error', e); });
163
+ }
164
+
165
+ function renderStats() {
166
+ if (!client) return;
167
+ const s = client.stats();
168
+ $('state').textContent = s.state;
169
+ $('dot').className = 'dot ' + s.state;
170
+ $('via').textContent = (s.host && s.host.transport) || s.transport;
171
+ $('rtt').textContent = s.rtt == null ? '—' : (s.rtt + ' ms');
172
+ $('frames').textContent = s.framesIn + '/' + s.framesOut;
173
+ $('bytes').textContent = s.bytesIn + '/' + s.bytesOut;
174
+ }
175
+
176
+ function renderSessions() {
177
+ if (!client) return;
178
+ const el = $('sessions');
179
+ const keys = Array.from(client.sessions.keys());
180
+ if (!keys.length) { el.innerHTML = '<div class="empty" style="margin-top:20px">no active sessions</div>'; return; }
181
+ el.innerHTML = '';
182
+ for (const k of keys) {
183
+ const snap = client.sessions.get(k);
184
+ const div = document.createElement('div');
185
+ div.className = 'sess' + (k === selected ? ' sel' : '');
186
+ div.onclick = () => { selected = k; renderSessions(); renderView(client.sessions.get(k)); };
187
+ const phase = (snap && snap.phase) || 'idle';
188
+ div.innerHTML = '<div class="k">' + esc(k) + '</div><div class="t">' + esc((snap && (snap.prompt || snap.text)) || '(new)') + '</div>';
189
+ const badge = document.createElement('span');
190
+ badge.className = 'badge ' + phase; badge.textContent = phase;
191
+ div.querySelector('.k').appendChild(document.createTextNode(' ')); div.querySelector('.k').appendChild(badge);
192
+ el.appendChild(div);
193
+ }
194
+ }
195
+
196
+ function renderView(snap) {
197
+ const v = $('view');
198
+ if (!snap) { v.innerHTML = '<div class="empty">no snapshot</div>'; return; }
199
+ const frag = document.createElement('div');
200
+
201
+ const meta = document.createElement('div'); meta.className = 'meta';
202
+ meta.innerHTML = 'phase <b>' + esc(snap.phase) + '</b> · agent <b>' + esc(snap.agent || '—') + '</b> · model <b>' + esc(snap.model || '—') + '</b> · effort <b>' + esc(snap.effort || '—') + '</b>';
203
+ if (snap.usage) meta.innerHTML += ' · ctx <b>' + (snap.usage.contextPercent != null ? snap.usage.contextPercent + '%' : '—') + '</b> · out <b>' + (snap.usage.turnOutputTokens || snap.usage.outputTokens || 0) + '</b> tok';
204
+ frag.appendChild(meta);
205
+
206
+ if (snap.prompt) frag.appendChild(card('Prompt', preEl(snap.prompt, 'prompt')));
207
+ if (snap.interactions && snap.interactions.length) snap.interactions.forEach((it) => frag.appendChild(interactionEl(it)));
208
+ if (snap.text) frag.appendChild(card('Response', preEl(snap.text)));
209
+ if (snap.reasoning) { const d = document.createElement('details'); d.innerHTML = '<summary>Thinking</summary>'; d.appendChild(preEl(snap.reasoning, 'reason')); frag.appendChild(card('Reasoning', d)); }
210
+ if (snap.plan && snap.plan.steps && snap.plan.steps.length) frag.appendChild(card('Plan', planEl(snap.plan)));
211
+ if (snap.toolCalls && snap.toolCalls.length) frag.appendChild(card('Tool calls', toolsEl(snap.toolCalls)));
212
+ else if (snap.activity) frag.appendChild(card('Activity', preEl(snap.activity, 'reason')));
213
+ if (snap.subAgents && snap.subAgents.length) frag.appendChild(card('Sub-agents', preEl(snap.subAgents.map((s) => '• ' + (s.kind || '?') + ': ' + (s.description || '') + ' [' + s.status + ']').join('\n'), 'reason')));
214
+ if (snap.artifacts && snap.artifacts.length) frag.appendChild(card('Artifacts', artifactsEl(snap.artifacts)));
215
+ if (snap.error) frag.appendChild(card('Error', preEl(snap.error)));
216
+
217
+ v.innerHTML = ''; v.appendChild(frag);
218
+ }
219
+
220
+ function card(title, body) { const c = document.createElement('div'); c.className = 'card'; const h = document.createElement('h4'); h.textContent = title; c.appendChild(h); c.appendChild(body); return c; }
221
+ function preEl(text, cls) { const p = document.createElement('pre'); if (cls) p.className = cls; p.textContent = String(text); return p; }
222
+ function planEl(plan) { const d = document.createElement('div'); if (plan.explanation) d.appendChild(preEl(plan.explanation, 'reason')); plan.steps.forEach((s) => { const r = document.createElement('div'); r.className = 'step ' + s.status; r.textContent = (s.status === 'completed' ? '✓ ' : s.status === 'inProgress' ? '▶ ' : '○ ') + s.text; d.appendChild(r); }); return d; }
223
+ function toolsEl(tools) { const d = document.createElement('div'); tools.forEach((t) => { const r = document.createElement('div'); r.className = 'tool'; r.innerHTML = '<b>' + esc(t.name) + '</b> ' + esc(t.summary || '') + ' <span class="badge ' + (t.status === 'done' ? 'done' : t.status === 'failed' ? 'queued' : 'streaming') + '">' + t.status + '</span>'; d.appendChild(r); }); return d; }
224
+ function artifactsEl(arts) { const d = document.createElement('div'); arts.forEach((a) => { const r = document.createElement('div'); const link = document.createElement('a'); link.href = a.url; link.target = '_blank'; link.textContent = a.fileName + ' (' + a.mime + ', ' + a.fileSize + 'B)'; r.appendChild(link); d.appendChild(r); }); return d; }
225
+ function interactionEl(it) {
226
+ const d = document.createElement('div'); d.className = 'interaction';
227
+ const q = it.questions && it.questions[it.currentIndex || 0];
228
+ d.innerHTML = '<b>' + esc(it.title || 'Agent asks') + '</b>' + (it.hint ? '<div class="reason">' + esc(it.hint) + '</div>' : '');
229
+ if (q) {
230
+ const qt = document.createElement('div'); qt.style.margin = '6px 0'; qt.textContent = q.text; d.appendChild(qt);
231
+ if (q.choices && q.choices.length) {
232
+ q.choices.forEach((c) => { const b = document.createElement('button'); b.className = 'btn choice'; b.textContent = c.label; b.onclick = () => client.interact(it.promptId, 'select', c.label); d.appendChild(b); });
233
+ } else {
234
+ const inp = document.createElement('input'); inp.placeholder = 'type answer + Enter'; inp.style.width = '60%';
235
+ inp.onkeydown = (e) => { if (e.key === 'Enter') { client.interact(it.promptId, 'text', inp.value); inp.value = ''; } };
236
+ d.appendChild(inp);
237
+ }
238
+ const skip = document.createElement('button'); skip.className = 'btn choice'; skip.textContent = 'skip'; skip.onclick = () => client.interact(it.promptId, 'skip'); d.appendChild(skip);
239
+ }
240
+ return d;
241
+ }
242
+
243
+ function esc(s) { const d = document.createElement('div'); d.textContent = String(s == null ? '' : s); return d.innerHTML; }
244
+ </script>
245
+ </body>
246
+ </html>