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.
- package/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-G0uiUjBk.js} +1 -1
- package/dashboard/dist/assets/ConnectionModal-BQvV5ok2.js +1 -0
- package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-BGacUCRb.js} +1 -1
- package/dashboard/dist/assets/ExtensionsTab-Bd2rjOVu.js +1 -0
- package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-BD-ZwAOb.js} +1 -1
- package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-BwmINu44.js} +1 -1
- package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-ncL7g7fW.js} +1 -1
- package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D7Iz4BLf.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-zxiA8Pfd.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-DZjEZmVB.js} +1 -1
- package/dashboard/dist/assets/{index-7pyJSn4B.js → index-CTHVuMnd.js} +17 -17
- package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
- package/dashboard/dist/assets/index-DEvh1R9o.js +3 -0
- package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-D5VHAaFg.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/index.js +1 -1
- package/dist/agent/skill-installer.js +95 -0
- package/dist/catalog/skill-repos.js +12 -0
- package/dist/cli/main.js +31 -1
- package/dist/core/secrets/ref.js +8 -0
- package/dist/core/secrets/store.js +31 -11
- package/dist/dashboard/routes/extensions.js +137 -24
- package/dist/dashboard/server.js +27 -64
- package/dist/pikichannel/adapter-pikiloom.js +220 -0
- package/dist/pikichannel/code.js +35 -0
- package/dist/pikichannel/codec.js +50 -0
- package/dist/pikichannel/host.js +252 -0
- package/dist/pikichannel/protocol.js +105 -0
- package/dist/pikichannel/rendezvous-broker.js +114 -0
- package/dist/pikichannel/rendezvous-host.js +138 -0
- package/dist/pikichannel/server.js +284 -0
- package/dist/pikichannel/transport.js +49 -0
- package/dist/pikichannel/transports/webrtc-host.js +61 -0
- package/dist/pikichannel/transports/webrtc-shared.js +132 -0
- package/dist/pikichannel/transports/websocket-host.js +78 -0
- package/dist/pikichannel/web/demo.html +246 -0
- package/dist/pikichannel/web/sdk.js +361 -0
- package/package.json +4 -2
- package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
- package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
- package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/adapter-pikiloom.ts — the reference {@link SessionSource}.
|
|
3
|
+
*
|
|
4
|
+
* Binds the universal protocol to pikiloom's live runtime *without touching the
|
|
5
|
+
* bot*. It is a peer consumer of the very same surfaces the Web Dashboard uses:
|
|
6
|
+
* - data plane: the `runtime.events` 'dashboard-event' bus (same bus the
|
|
7
|
+
* dashboard WebSocket layer listens to). We must NOT call
|
|
8
|
+
* `bot.onStreamSnapshot` — that is a single-slot callback already owned by
|
|
9
|
+
* the runtime; re-registering it would silently break the dashboard.
|
|
10
|
+
* - control plane: the exported session-control functions
|
|
11
|
+
* (`queueDashboardSessionTask`, `stopSessionTasks`, …) — the same entry
|
|
12
|
+
* points the dashboard REST routes call.
|
|
13
|
+
*
|
|
14
|
+
* The StreamSnapshot → UniversalSnapshot projection lives here, keeping the
|
|
15
|
+
* protocol package free of any pikiloom-internal type.
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from 'node:events';
|
|
18
|
+
import { runtime } from '../dashboard/runtime.js';
|
|
19
|
+
import { queueDashboardSessionTask, stopSessionTasks, steerSessionTask, cancelSessionTask, interactionSelectOption, interactionSubmitText, interactionSkip, interactionCancel, } from '../dashboard/session-control.js';
|
|
20
|
+
import { loadUserConfig } from '../core/config/user-config.js';
|
|
21
|
+
import { VERSION } from '../core/version.js';
|
|
22
|
+
const CAPABILITIES = ['prompt', 'stop', 'steer', 'recall', 'interact', 'subscribe-all', 'artifacts', 'tunnel'];
|
|
23
|
+
function splitKey(sessionKey) {
|
|
24
|
+
const idx = sessionKey.indexOf(':');
|
|
25
|
+
if (idx < 0)
|
|
26
|
+
return { agent: '', sessionId: sessionKey };
|
|
27
|
+
return { agent: sessionKey.slice(0, idx), sessionId: sessionKey.slice(idx + 1) };
|
|
28
|
+
}
|
|
29
|
+
function projectInteractions(snap) {
|
|
30
|
+
if (!snap.interactions?.length)
|
|
31
|
+
return undefined;
|
|
32
|
+
return snap.interactions.map((it) => ({
|
|
33
|
+
promptId: it.promptId,
|
|
34
|
+
kind: it.kind,
|
|
35
|
+
title: it.title,
|
|
36
|
+
hint: it.hint,
|
|
37
|
+
currentIndex: it.currentIndex,
|
|
38
|
+
questions: (it.questions || []).map((q) => ({
|
|
39
|
+
id: String(q.id),
|
|
40
|
+
text: String(q.text ?? q.label ?? ''),
|
|
41
|
+
type: q.type,
|
|
42
|
+
choices: Array.isArray(q.choices)
|
|
43
|
+
? q.choices.map((c) => ({ label: String(c.label ?? c), description: c.description }))
|
|
44
|
+
: undefined,
|
|
45
|
+
})),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
/** Strip empty optionals so the projection is lean AND deterministic (the diff
|
|
49
|
+
* baseline must be stable: a field is either consistently present or absent). */
|
|
50
|
+
function compact(obj) {
|
|
51
|
+
for (const k of Object.keys(obj)) {
|
|
52
|
+
const v = obj[k];
|
|
53
|
+
const emptyArr = Array.isArray(v) && v.length === 0;
|
|
54
|
+
const emptyObj = v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0;
|
|
55
|
+
if (v === null || v === undefined || v === '' || emptyArr || emptyObj)
|
|
56
|
+
delete obj[k];
|
|
57
|
+
}
|
|
58
|
+
return obj;
|
|
59
|
+
}
|
|
60
|
+
/** Project pikiloom's StreamSnapshot into the agent-agnostic wire snapshot. */
|
|
61
|
+
export function projectSnapshot(sessionKey, snap) {
|
|
62
|
+
const { agent } = splitKey(sessionKey);
|
|
63
|
+
const meta = snap.previewMeta;
|
|
64
|
+
return compact({
|
|
65
|
+
phase: snap.phase,
|
|
66
|
+
taskId: snap.taskId ?? undefined,
|
|
67
|
+
agent: agent || undefined,
|
|
68
|
+
model: snap.model ?? undefined,
|
|
69
|
+
effort: snap.effort ?? undefined,
|
|
70
|
+
prompt: snap.question ?? undefined,
|
|
71
|
+
text: snap.text || undefined,
|
|
72
|
+
reasoning: snap.thinking || undefined,
|
|
73
|
+
activity: snap.activity || undefined,
|
|
74
|
+
plan: snap.plan
|
|
75
|
+
? { explanation: snap.plan.explanation, steps: snap.plan.steps.map((s) => ({ text: s.step, status: s.status })) }
|
|
76
|
+
: undefined,
|
|
77
|
+
toolCalls: meta?.toolCalls?.map((t) => ({
|
|
78
|
+
id: t.id, name: t.name, summary: t.summary, input: t.input, result: t.result, status: t.status,
|
|
79
|
+
})),
|
|
80
|
+
subAgents: meta?.subAgents?.map((s) => ({
|
|
81
|
+
id: s.id, kind: s.kind, description: s.description, model: s.model, tools: s.tools, status: s.status,
|
|
82
|
+
})),
|
|
83
|
+
usage: meta
|
|
84
|
+
? compact({
|
|
85
|
+
inputTokens: meta.inputTokens,
|
|
86
|
+
outputTokens: meta.outputTokens,
|
|
87
|
+
cachedInputTokens: meta.cachedInputTokens,
|
|
88
|
+
contextUsedTokens: meta.contextUsedTokens,
|
|
89
|
+
contextPercent: meta.contextPercent,
|
|
90
|
+
turnOutputTokens: meta.turnOutputTokens,
|
|
91
|
+
providerName: meta.providerName,
|
|
92
|
+
generatingImages: meta.generatingImages,
|
|
93
|
+
})
|
|
94
|
+
: undefined,
|
|
95
|
+
artifacts: snap.artifacts,
|
|
96
|
+
interactions: projectInteractions(snap),
|
|
97
|
+
queued: snap.queuedTasks?.length ? snap.queuedTasks.map((q) => ({ taskId: q.taskId, prompt: q.prompt })) : undefined,
|
|
98
|
+
error: snap.error ?? undefined,
|
|
99
|
+
...(snap.incomplete ? { incomplete: true } : {}),
|
|
100
|
+
startedAt: snap.startedAt,
|
|
101
|
+
updatedAt: snap.updatedAt,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function metaFromSnapshot(sessionKey, snap) {
|
|
105
|
+
const title = (snap.prompt || snap.text || '').slice(0, 80) || null;
|
|
106
|
+
return { sessionKey, agent: snap.agent, title, phase: snap.phase, updatedAt: snap.updatedAt };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The pikiloom SessionSource. One instance per process; it attaches to the
|
|
110
|
+
* runtime event bus lazily on first `onUpdate`.
|
|
111
|
+
*/
|
|
112
|
+
export class PikiloomSessionSource {
|
|
113
|
+
forwarder;
|
|
114
|
+
seqs = new Map();
|
|
115
|
+
known = new Map();
|
|
116
|
+
bus = new EventEmitter();
|
|
117
|
+
wired = false;
|
|
118
|
+
/** @param forwarder forwards tunneled `/api/*` requests to the HTTP router. */
|
|
119
|
+
constructor(forwarder) {
|
|
120
|
+
this.forwarder = forwarder;
|
|
121
|
+
}
|
|
122
|
+
hostInfo() {
|
|
123
|
+
return { name: 'pikiloom', version: VERSION, capabilities: CAPABILITIES, authRequired: true };
|
|
124
|
+
}
|
|
125
|
+
/** Control-plane HTTP tunnel — delegates to the embedder-supplied forwarder
|
|
126
|
+
* (the dashboard's Hono router). The host has already enforced auth + the
|
|
127
|
+
* `/api/*` allowlist before we get here. */
|
|
128
|
+
async handleRequest(req) {
|
|
129
|
+
if (!this.forwarder)
|
|
130
|
+
return { status: 503, body: 'tunnel forwarder not configured' };
|
|
131
|
+
return this.forwarder(req);
|
|
132
|
+
}
|
|
133
|
+
listSessions() {
|
|
134
|
+
// Most-recent first, bounded so a long-lived host never ships an unbounded
|
|
135
|
+
// list over the wire (remote clients want a recent picker, not all history).
|
|
136
|
+
return Array.from(this.known.values()).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).slice(0, 50);
|
|
137
|
+
}
|
|
138
|
+
getSnapshot(sessionKey) {
|
|
139
|
+
const bot = runtime.getBotRef();
|
|
140
|
+
const raw = bot?.getStreamSnapshot(sessionKey);
|
|
141
|
+
if (!raw)
|
|
142
|
+
return null;
|
|
143
|
+
return { snapshot: projectSnapshot(sessionKey, raw), seq: this.seqs.get(sessionKey) || 0 };
|
|
144
|
+
}
|
|
145
|
+
onUpdate(cb) {
|
|
146
|
+
this.ensureWired();
|
|
147
|
+
const handler = (k, s, seq) => cb(k, s, seq);
|
|
148
|
+
this.bus.on('update', handler);
|
|
149
|
+
return () => this.bus.off('update', handler);
|
|
150
|
+
}
|
|
151
|
+
onSessionsChanged(cb) {
|
|
152
|
+
this.ensureWired();
|
|
153
|
+
const handler = () => cb(this.listSessions());
|
|
154
|
+
this.bus.on('sessions', handler);
|
|
155
|
+
return () => this.bus.off('sessions', handler);
|
|
156
|
+
}
|
|
157
|
+
/** Subscribe once to the runtime's dashboard-event bus and fan out internally. */
|
|
158
|
+
ensureWired() {
|
|
159
|
+
if (this.wired)
|
|
160
|
+
return;
|
|
161
|
+
this.wired = true;
|
|
162
|
+
runtime.events.on('dashboard-event', (event) => {
|
|
163
|
+
if (event.type === 'stream-update' && event.key && event.snapshot) {
|
|
164
|
+
const snapshot = projectSnapshot(event.key, event.snapshot);
|
|
165
|
+
const seq = (this.seqs.get(event.key) || 0) + 1;
|
|
166
|
+
this.seqs.set(event.key, seq);
|
|
167
|
+
this.known.set(event.key, metaFromSnapshot(event.key, snapshot));
|
|
168
|
+
this.bus.emit('update', event.key, snapshot, seq);
|
|
169
|
+
}
|
|
170
|
+
else if (event.type === 'sessions-changed') {
|
|
171
|
+
this.bus.emit('sessions');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// -- control plane (delegates to the shared session-control surface) -----
|
|
176
|
+
async prompt(cmd) {
|
|
177
|
+
const config = loadUserConfig();
|
|
178
|
+
const workdir = cmd.workdir || runtime.getRequestWorkdir(config);
|
|
179
|
+
const agent = cmd.agent || runtime.getRuntimeDefaultAgent(config);
|
|
180
|
+
const sessionId = cmd.sessionKey ? splitKey(cmd.sessionKey).sessionId : '';
|
|
181
|
+
const res = await queueDashboardSessionTask({
|
|
182
|
+
workdir,
|
|
183
|
+
agent,
|
|
184
|
+
sessionId,
|
|
185
|
+
prompt: cmd.prompt,
|
|
186
|
+
model: cmd.model,
|
|
187
|
+
effort: cmd.effort,
|
|
188
|
+
workflow: cmd.workflow,
|
|
189
|
+
attachments: cmd.attachments,
|
|
190
|
+
});
|
|
191
|
+
if (!res?.ok)
|
|
192
|
+
return { ok: false, error: res?.error || 'submit failed' };
|
|
193
|
+
return { ok: true, sessionKey: res.sessionKey, taskId: res.taskId };
|
|
194
|
+
}
|
|
195
|
+
stop(sessionKey) {
|
|
196
|
+
const { agent, sessionId } = splitKey(sessionKey);
|
|
197
|
+
const r = stopSessionTasks(agent, sessionId);
|
|
198
|
+
return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'stop failed' };
|
|
199
|
+
}
|
|
200
|
+
async steer(taskId) {
|
|
201
|
+
const r = await steerSessionTask(taskId);
|
|
202
|
+
return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'steer failed' };
|
|
203
|
+
}
|
|
204
|
+
recall(taskId) {
|
|
205
|
+
const r = cancelSessionTask(taskId);
|
|
206
|
+
return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'recall failed' };
|
|
207
|
+
}
|
|
208
|
+
interact(promptId, action, value, requestFreeform) {
|
|
209
|
+
let r;
|
|
210
|
+
if (action === 'select')
|
|
211
|
+
r = interactionSelectOption(promptId, value || '', { requestFreeform });
|
|
212
|
+
else if (action === 'text')
|
|
213
|
+
r = interactionSubmitText(promptId, value || '');
|
|
214
|
+
else if (action === 'skip')
|
|
215
|
+
r = interactionSkip(promptId);
|
|
216
|
+
else
|
|
217
|
+
r = interactionCancel(promptId);
|
|
218
|
+
return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'interaction failed' };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/code.ts — the shareable connection code (server side).
|
|
3
|
+
*
|
|
4
|
+
* One short string a host shows and a client pastes. Same format the browser
|
|
5
|
+
* `endpoint.ts` decodes — keep the keys (h/r/n/t) in lockstep:
|
|
6
|
+
* h = direct host authority r = rendezvous URL n = nodeId t = token
|
|
7
|
+
*
|
|
8
|
+
* `buildServerCode` picks the best reachability for THIS host:
|
|
9
|
+
* - public address set → DIRECT code {h,t} (public-IP server; no NAT hop)
|
|
10
|
+
* - else rendezvous on → REMOTE code {r,n,t} (NAT traversal)
|
|
11
|
+
* - else → none (only reachable locally)
|
|
12
|
+
*/
|
|
13
|
+
export function encodeConnectionCode(d) {
|
|
14
|
+
const lean = {};
|
|
15
|
+
if (d.host)
|
|
16
|
+
lean.h = d.host;
|
|
17
|
+
if (d.rendezvous)
|
|
18
|
+
lean.r = d.rendezvous;
|
|
19
|
+
if (d.nodeId)
|
|
20
|
+
lean.n = d.nodeId;
|
|
21
|
+
if (d.token)
|
|
22
|
+
lean.t = d.token;
|
|
23
|
+
return Buffer.from(JSON.stringify(lean), 'utf8').toString('base64url');
|
|
24
|
+
}
|
|
25
|
+
export function buildServerCode(opts) {
|
|
26
|
+
const token = (opts.token || '').trim();
|
|
27
|
+
const publicHost = (opts.publicHost || '').trim();
|
|
28
|
+
const rendezvous = (opts.rendezvous || '').trim();
|
|
29
|
+
const nodeId = (opts.nodeId || '').trim();
|
|
30
|
+
if (publicHost)
|
|
31
|
+
return { mode: 'direct', code: encodeConnectionCode({ host: publicHost, token }), detail: publicHost };
|
|
32
|
+
if (rendezvous && nodeId)
|
|
33
|
+
return { mode: 'remote', code: encodeConnectionCode({ rendezvous, nodeId, token }), detail: rendezvous };
|
|
34
|
+
return { mode: 'none', code: '', detail: '' };
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/codec.ts — frame (de)serialization for the L2 protocol.
|
|
3
|
+
*
|
|
4
|
+
* The transport layer (L1) moves opaque frames; this module is the only place
|
|
5
|
+
* that knows how a {@link ServerMessage} / {@link ClientMessage} becomes a frame
|
|
6
|
+
* and back. Today that is newline-free JSON text. The indirection is deliberate:
|
|
7
|
+
* swapping to a binary codec (CBOR / protobuf) later is a change here alone, with
|
|
8
|
+
* the wire-format tag negotiated in the handshake — no transport or host edits.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_WIRE_FORMAT = 'json';
|
|
11
|
+
/** Encode a host→client message into a transport frame. */
|
|
12
|
+
export function encodeServer(msg, _fmt = DEFAULT_WIRE_FORMAT) {
|
|
13
|
+
return JSON.stringify(msg);
|
|
14
|
+
}
|
|
15
|
+
/** Encode a client→host message into a transport frame. */
|
|
16
|
+
export function encodeClient(msg, _fmt = DEFAULT_WIRE_FORMAT) {
|
|
17
|
+
return JSON.stringify(msg);
|
|
18
|
+
}
|
|
19
|
+
/** Coerce a transport frame (string / Buffer / ArrayBuffer) into text. */
|
|
20
|
+
function frameToText(raw) {
|
|
21
|
+
if (typeof raw === 'string')
|
|
22
|
+
return raw;
|
|
23
|
+
if (Buffer.isBuffer(raw))
|
|
24
|
+
return raw.toString('utf8');
|
|
25
|
+
return Buffer.from(new Uint8Array(raw)).toString('utf8');
|
|
26
|
+
}
|
|
27
|
+
/** Decode a client→host frame. Returns null on malformed input (never throws). */
|
|
28
|
+
export function decodeClient(raw, _fmt = DEFAULT_WIRE_FORMAT) {
|
|
29
|
+
try {
|
|
30
|
+
const value = JSON.parse(frameToText(raw));
|
|
31
|
+
if (!value || typeof value !== 'object' || typeof value.type !== 'string')
|
|
32
|
+
return null;
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Decode a host→client frame. Returns null on malformed input (never throws). */
|
|
40
|
+
export function decodeServer(raw, _fmt = DEFAULT_WIRE_FORMAT) {
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(frameToText(raw));
|
|
43
|
+
if (!value || typeof value !== 'object' || typeof value.type !== 'string')
|
|
44
|
+
return null;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/host.ts — the host SDK (transport- and agent-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* `PikichannelHost` speaks the L2 protocol over any {@link ChannelConnection},
|
|
5
|
+
* driven by a {@link SessionSource} the embedder supplies. The host knows
|
|
6
|
+
* nothing about pikiloom: porting pikichannel to another agent runtime is a
|
|
7
|
+
* matter of implementing SessionSource (see adapter-pikiloom.ts).
|
|
8
|
+
*
|
|
9
|
+
* It owns three cross-cutting concerns the protocol requires:
|
|
10
|
+
* - Auth: a peer must pass `authenticate(token, remote)` (loopback exempt by
|
|
11
|
+
* policy) before ANY session data or control is processed.
|
|
12
|
+
* - Delta: it holds the latest full snapshot per session and emits a `full`
|
|
13
|
+
* patch on (re)subscribe / resync, deltas thereafter — O(n) per stream.
|
|
14
|
+
* - Fan-out: one delta per update is broadcast to every caught-up subscriber;
|
|
15
|
+
* a fresh subscriber gets a `full` so it shares the same baseline.
|
|
16
|
+
*/
|
|
17
|
+
import { PROTOCOL_VERSION, diffSnapshot, } from './protocol.js';
|
|
18
|
+
import { encodeServer, decodeClient } from './codec.js';
|
|
19
|
+
const SUBSCRIBE_ALL = '*';
|
|
20
|
+
export class PikichannelHost {
|
|
21
|
+
source;
|
|
22
|
+
authenticate;
|
|
23
|
+
log;
|
|
24
|
+
peers = new Set();
|
|
25
|
+
unsubscribers = [];
|
|
26
|
+
started = false;
|
|
27
|
+
/** Latest full snapshot per session — the delta baseline shared by all peers. */
|
|
28
|
+
lastFull = new Map();
|
|
29
|
+
lastSeq = new Map();
|
|
30
|
+
constructor(source, authenticate = () => true, log = () => { }) {
|
|
31
|
+
this.source = source;
|
|
32
|
+
this.authenticate = authenticate;
|
|
33
|
+
this.log = log;
|
|
34
|
+
}
|
|
35
|
+
/** Wire host-level subscriptions to the source (once). */
|
|
36
|
+
start() {
|
|
37
|
+
if (this.started)
|
|
38
|
+
return;
|
|
39
|
+
this.started = true;
|
|
40
|
+
this.unsubscribers.push(this.source.onUpdate((sessionKey, snapshot, seq) => this.onSourceUpdate(sessionKey, snapshot, seq)));
|
|
41
|
+
this.unsubscribers.push(this.source.onSessionsChanged((sessions) => this.broadcast({ type: 'sessions', sessions }, true)));
|
|
42
|
+
}
|
|
43
|
+
stop() {
|
|
44
|
+
for (const u of this.unsubscribers.splice(0)) {
|
|
45
|
+
try {
|
|
46
|
+
u();
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore */ }
|
|
49
|
+
}
|
|
50
|
+
for (const peer of this.peers) {
|
|
51
|
+
try {
|
|
52
|
+
peer.conn.close();
|
|
53
|
+
}
|
|
54
|
+
catch { /* ignore */ }
|
|
55
|
+
}
|
|
56
|
+
this.peers.clear();
|
|
57
|
+
this.lastFull.clear();
|
|
58
|
+
this.lastSeq.clear();
|
|
59
|
+
this.started = false;
|
|
60
|
+
}
|
|
61
|
+
/** Adopt a freshly-established connection from any transport binding. */
|
|
62
|
+
handleConnection(conn) {
|
|
63
|
+
if (!this.started)
|
|
64
|
+
this.start();
|
|
65
|
+
const peer = { conn, subs: new Set(), authed: false };
|
|
66
|
+
this.peers.add(peer);
|
|
67
|
+
this.log(`peer connected id=${conn.id} via=${conn.kind} remote=${conn.remote || '?'}`);
|
|
68
|
+
conn.onMessage((frame) => {
|
|
69
|
+
const msg = decodeClient(frame);
|
|
70
|
+
if (!msg)
|
|
71
|
+
return;
|
|
72
|
+
this.handleClientMessage(peer, msg).catch((err) => {
|
|
73
|
+
this.send(peer, { type: 'error', message: err?.message || String(err) });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
conn.onClose(() => {
|
|
77
|
+
this.peers.delete(peer);
|
|
78
|
+
this.log(`peer disconnected id=${conn.id} via=${conn.kind}`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// -- delta baseline ------------------------------------------------------
|
|
82
|
+
onSourceUpdate(sessionKey, full, seq) {
|
|
83
|
+
const prev = this.lastFull.get(sessionKey);
|
|
84
|
+
const patch = prev ? diffSnapshot(prev, full) : { full };
|
|
85
|
+
this.lastFull.set(sessionKey, full);
|
|
86
|
+
this.lastSeq.set(sessionKey, seq);
|
|
87
|
+
const frame = { type: 'session', sessionKey, seq, patch };
|
|
88
|
+
for (const peer of this.peers) {
|
|
89
|
+
if (peer.authed && (peer.subs.has(SUBSCRIBE_ALL) || peer.subs.has(sessionKey)))
|
|
90
|
+
this.send(peer, frame);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Send the current full snapshot for a session to one peer (subscribe / resync). */
|
|
94
|
+
sendFull(peer, sessionKey) {
|
|
95
|
+
let full = this.lastFull.get(sessionKey);
|
|
96
|
+
let seq = this.lastSeq.get(sessionKey);
|
|
97
|
+
if (!full) {
|
|
98
|
+
const snap = this.source.getSnapshot(sessionKey);
|
|
99
|
+
if (!snap)
|
|
100
|
+
return;
|
|
101
|
+
full = snap.snapshot;
|
|
102
|
+
seq = snap.seq;
|
|
103
|
+
this.lastFull.set(sessionKey, full);
|
|
104
|
+
this.lastSeq.set(sessionKey, seq);
|
|
105
|
+
}
|
|
106
|
+
this.send(peer, { type: 'session', sessionKey, seq: seq || 0, patch: { full } });
|
|
107
|
+
}
|
|
108
|
+
// -- inbound -------------------------------------------------------------
|
|
109
|
+
async handleClientMessage(peer, msg) {
|
|
110
|
+
// Ping is always allowed (RTT / keepalive). Everything else is gated on auth.
|
|
111
|
+
if (msg.type === 'ping') {
|
|
112
|
+
this.send(peer, { type: 'pong', t: msg.t });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!peer.authed) {
|
|
116
|
+
if (msg.type !== 'hello') {
|
|
117
|
+
this.send(peer, { type: 'error', message: 'not authenticated', code: 'auth' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!this.authenticate(msg.token, peer.conn.remote)) {
|
|
121
|
+
this.log(`peer rejected id=${peer.conn.id} remote=${peer.conn.remote || '?'} (bad/absent token)`);
|
|
122
|
+
this.send(peer, { type: 'error', message: 'unauthorized', code: 'auth' });
|
|
123
|
+
peer.conn.close();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
peer.authed = true;
|
|
127
|
+
const info = this.source.hostInfo();
|
|
128
|
+
this.send(peer, {
|
|
129
|
+
type: 'welcome',
|
|
130
|
+
v: PROTOCOL_VERSION,
|
|
131
|
+
host: {
|
|
132
|
+
name: info.name,
|
|
133
|
+
version: info.version,
|
|
134
|
+
transport: peer.conn.kind,
|
|
135
|
+
capabilities: info.capabilities,
|
|
136
|
+
authRequired: info.authRequired,
|
|
137
|
+
},
|
|
138
|
+
sessions: this.source.listSessions(),
|
|
139
|
+
});
|
|
140
|
+
if (msg.resume?.sessionKey) {
|
|
141
|
+
peer.subs.add(msg.resume.sessionKey);
|
|
142
|
+
this.sendFull(peer, msg.resume.sessionKey);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
switch (msg.type) {
|
|
147
|
+
case 'hello':
|
|
148
|
+
return; // already authenticated; ignore duplicate hello
|
|
149
|
+
case 'subscribe': {
|
|
150
|
+
peer.subs.add(msg.sessionKey);
|
|
151
|
+
if (msg.sessionKey === SUBSCRIBE_ALL) {
|
|
152
|
+
for (const meta of this.source.listSessions())
|
|
153
|
+
this.sendFull(peer, meta.sessionKey);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
this.sendFull(peer, msg.sessionKey);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case 'unsubscribe':
|
|
161
|
+
peer.subs.delete(msg.sessionKey);
|
|
162
|
+
return;
|
|
163
|
+
case 'getSnapshot':
|
|
164
|
+
this.sendFull(peer, msg.sessionKey);
|
|
165
|
+
return;
|
|
166
|
+
case 'listSessions':
|
|
167
|
+
this.send(peer, { type: 'sessions', sessions: this.source.listSessions() });
|
|
168
|
+
return;
|
|
169
|
+
case 'prompt': {
|
|
170
|
+
const result = await this.source.prompt({
|
|
171
|
+
sessionKey: msg.sessionKey, prompt: msg.prompt, agent: msg.agent, workdir: msg.workdir,
|
|
172
|
+
model: msg.model, effort: msg.effort, workflow: msg.workflow, attachments: msg.attachments,
|
|
173
|
+
});
|
|
174
|
+
if (result.ok && result.sessionKey) {
|
|
175
|
+
peer.subs.add(result.sessionKey);
|
|
176
|
+
this.send(peer, { type: 'accepted', sessionKey: result.sessionKey, taskId: result.taskId || '', clientRef: msg.clientRef });
|
|
177
|
+
this.sendFull(peer, result.sessionKey);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.send(peer, { type: 'error', message: result.error || 'prompt rejected', clientRef: msg.clientRef });
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case 'stop': {
|
|
185
|
+
const r = this.source.stop(msg.sessionKey);
|
|
186
|
+
if (!r.ok)
|
|
187
|
+
this.send(peer, { type: 'error', message: r.error || 'stop failed' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
case 'steer': {
|
|
191
|
+
const r = await this.source.steer(msg.taskId);
|
|
192
|
+
if (!r.ok)
|
|
193
|
+
this.send(peer, { type: 'error', message: r.error || 'steer failed' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case 'recall': {
|
|
197
|
+
const r = this.source.recall(msg.taskId);
|
|
198
|
+
if (!r.ok)
|
|
199
|
+
this.send(peer, { type: 'error', message: r.error || 'recall failed' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case 'interact': {
|
|
203
|
+
const r = this.source.interact(msg.promptId, msg.action, msg.value, msg.requestFreeform);
|
|
204
|
+
if (!r.ok)
|
|
205
|
+
this.send(peer, { type: 'error', message: r.error || 'interaction failed' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
case 'request': {
|
|
209
|
+
const id = msg.id;
|
|
210
|
+
// Only the management API is tunnelable — never static, the SPA shell,
|
|
211
|
+
// or loopback-gated endpoints (e.g. /pikichannel/pair would otherwise
|
|
212
|
+
// leak the token to a remote peer via the host's own fetch).
|
|
213
|
+
if (!msg.path || !msg.path.startsWith('/api/')) {
|
|
214
|
+
this.send(peer, { type: 'response', id, status: 403, error: 'only /api/* is tunnelable' });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!this.source.handleRequest) {
|
|
218
|
+
this.send(peer, { type: 'response', id, status: 501, error: 'tunnel not supported' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const r = await this.source.handleRequest({ method: msg.method, path: msg.path, headers: msg.headers, body: msg.body, encoding: msg.encoding });
|
|
223
|
+
this.send(peer, { type: 'response', id, status: r.status, headers: r.headers, body: r.body, encoding: r.encoding });
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
this.send(peer, { type: 'response', id, status: 500, error: err?.message || 'tunnel error' });
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// -- outbound ------------------------------------------------------------
|
|
233
|
+
broadcast(frame, authedOnly = false) {
|
|
234
|
+
for (const peer of this.peers)
|
|
235
|
+
if (!authedOnly || peer.authed)
|
|
236
|
+
this.send(peer, frame);
|
|
237
|
+
}
|
|
238
|
+
send(peer, frame) {
|
|
239
|
+
if (!peer.conn.isOpen())
|
|
240
|
+
return;
|
|
241
|
+
try {
|
|
242
|
+
peer.conn.send(encodeServer(frame));
|
|
243
|
+
}
|
|
244
|
+
catch { /* drop on closed pipe */ }
|
|
245
|
+
}
|
|
246
|
+
/** Number of live peers (for status / metrics). */
|
|
247
|
+
get peerCount() { return this.peers.size; }
|
|
248
|
+
/** Number of authenticated peers. */
|
|
249
|
+
get authedPeerCount() { let n = 0; for (const p of this.peers)
|
|
250
|
+
if (p.authed)
|
|
251
|
+
n++; return n; }
|
|
252
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/protocol.ts — THE universal, agent-agnostic wire protocol (L2).
|
|
3
|
+
*
|
|
4
|
+
* This file is the single source of truth for the pikichannel contract. It is
|
|
5
|
+
* deliberately free of any pikiloom-internal or agent-specific type: a client
|
|
6
|
+
* SDK on any platform (Web / iOS / Android) only needs these shapes to speak to
|
|
7
|
+
* any pikichannel host. The browser SDK in `web/pikichannel-sdk.js` mirrors the
|
|
8
|
+
* string literals and shapes documented here — keep the two in lockstep.
|
|
9
|
+
*
|
|
10
|
+
* Layering:
|
|
11
|
+
* transport (L1) — moves opaque frames (a byte/string pipe): WebSocket, WebRTC
|
|
12
|
+
* datachannel, relay tunnel. Pluggable; see transport.ts.
|
|
13
|
+
* protocol (L2) — THIS file. The session/event semantics framed over L1.
|
|
14
|
+
*
|
|
15
|
+
* Design rules:
|
|
16
|
+
* - Every message is a flat discriminated union on `type`. No nested envelopes
|
|
17
|
+
* so clients can `switch (msg.type)` directly.
|
|
18
|
+
* - The host→client `session` event carries a {@link SnapshotPatch}: a `full`
|
|
19
|
+
* snapshot on first send / resync, then deltas (append-only suffixes for the
|
|
20
|
+
* unbounded text/reasoning fields + changed scalar/struct fields). The host
|
|
21
|
+
* remains the single source of truth — it holds the full snapshot and emits
|
|
22
|
+
* `full` on (re)subscribe or whenever a client reports a `seq` gap, so the
|
|
23
|
+
* wire is O(n) for a stream of total size n instead of O(n²).
|
|
24
|
+
* - `seq` is monotonic per session for ordering / gap detection.
|
|
25
|
+
* - Auth: a client authenticates in `hello` (token); loopback peers are exempt
|
|
26
|
+
* by host policy. No session data or control is processed before auth.
|
|
27
|
+
* - Versioned via PROTOCOL_VERSION; the handshake negotiates it.
|
|
28
|
+
*
|
|
29
|
+
* Extensibility (how this grows without forking the wire):
|
|
30
|
+
* - ADDITIVE by default: new optional fields on existing messages and brand-new
|
|
31
|
+
* `type`s are backward-compatible. Both host and client MUST ignore unknown
|
|
32
|
+
* message `type`s and unknown fields (the switch statements fall through, not
|
|
33
|
+
* throw) — so an old peer talks to a new peer safely.
|
|
34
|
+
* - Optional features are negotiated via `HostCapability` (advertised in
|
|
35
|
+
* `welcome`), not by version bumps — a client checks `host.capabilities`
|
|
36
|
+
* before using a non-core verb.
|
|
37
|
+
* - PROTOCOL_VERSION bumps only for a BREAKING change; `hello.v` lets either
|
|
38
|
+
* side detect a mismatch and degrade.
|
|
39
|
+
* - The transport (L1) is fully decoupled: a new binding (relay, WebTransport,
|
|
40
|
+
* QUIC) implements ChannelConnection and carries this protocol unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export const PROTOCOL_VERSION = 1;
|
|
43
|
+
/** An empty baseline snapshot, used as the starting point before any patch. */
|
|
44
|
+
export function emptySnapshot() {
|
|
45
|
+
return { phase: 'idle', updatedAt: 0 };
|
|
46
|
+
}
|
|
47
|
+
const APPEND_FIELDS = ['text', 'reasoning'];
|
|
48
|
+
const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued'];
|
|
49
|
+
const SCALAR_FIELDS = ['phase', 'taskId', 'agent', 'model', 'effort', 'prompt', 'activity', 'error', 'incomplete', 'startedAt', 'updatedAt'];
|
|
50
|
+
/**
|
|
51
|
+
* Diff two full snapshots into a minimal patch. text/reasoning are encoded as
|
|
52
|
+
* append suffixes when the next value extends the prev (the streaming common
|
|
53
|
+
* case); any non-extension falls back to a `set`. Bounded fields are compared by
|
|
54
|
+
* value and sent whole when changed.
|
|
55
|
+
*/
|
|
56
|
+
export function diffSnapshot(prev, next) {
|
|
57
|
+
const patch = {};
|
|
58
|
+
let set;
|
|
59
|
+
const put = (k, v) => { (set ||= {})[k] = v; };
|
|
60
|
+
for (const f of APPEND_FIELDS) {
|
|
61
|
+
const a = prev[f] ?? '';
|
|
62
|
+
const b = next[f] ?? '';
|
|
63
|
+
if (a === b)
|
|
64
|
+
continue;
|
|
65
|
+
if (typeof b === 'string' && typeof a === 'string' && b.startsWith(a)) {
|
|
66
|
+
if (f === 'text')
|
|
67
|
+
patch.appendText = b.slice(a.length);
|
|
68
|
+
else
|
|
69
|
+
patch.appendReasoning = b.slice(a.length);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
put(f, b);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const f of SCALAR_FIELDS) {
|
|
76
|
+
if (prev[f] !== next[f])
|
|
77
|
+
put(f, next[f]);
|
|
78
|
+
}
|
|
79
|
+
for (const f of STRUCT_FIELDS) {
|
|
80
|
+
if (JSON.stringify(prev[f]) !== JSON.stringify(next[f]))
|
|
81
|
+
put(f, next[f]);
|
|
82
|
+
}
|
|
83
|
+
if (set)
|
|
84
|
+
patch.set = set;
|
|
85
|
+
return patch;
|
|
86
|
+
}
|
|
87
|
+
/** Apply a patch onto a prior snapshot, returning the new cumulative snapshot. */
|
|
88
|
+
export function applySnapshotPatch(prev, patch) {
|
|
89
|
+
if (patch.full)
|
|
90
|
+
return patch.full;
|
|
91
|
+
const next = prev ? { ...prev } : emptySnapshot();
|
|
92
|
+
if (patch.appendText)
|
|
93
|
+
next.text = (next.text || '') + patch.appendText;
|
|
94
|
+
if (patch.appendReasoning)
|
|
95
|
+
next.reasoning = (next.reasoning || '') + patch.appendReasoning;
|
|
96
|
+
if (patch.set)
|
|
97
|
+
Object.assign(next, patch.set);
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Type guards (host-side convenience)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
export function isClientMessage(value) {
|
|
104
|
+
return !!value && typeof value === 'object' && typeof value.type === 'string';
|
|
105
|
+
}
|