pikiloom 0.4.21 → 0.4.23
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-hzdSX7gP.js} +1 -1
- package/dashboard/dist/assets/ConnectionModal-Dj2eoHWz.js +1 -0
- package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-C4DPPZ1V.js} +1 -1
- package/dashboard/dist/assets/ExtensionsTab-BdGHiEnP.js +1 -0
- package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-C4RlXeCP.js} +1 -1
- package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-D22Z00Ux.js} +1 -1
- package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-CHXLG4V2.js} +1 -1
- package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D4JIewGZ.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-Ba93D9jh.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-M8r-o2KI.js} +1 -1
- package/dashboard/dist/assets/index-C02W4e24.js +3 -0
- package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
- package/dashboard/dist/assets/{index-7pyJSn4B.js → index-p2XCgnzw.js} +17 -17
- package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-DW8iXiGd.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
package/dist/dashboard/server.js
CHANGED
|
@@ -9,7 +9,6 @@ import { serveStatic } from '@hono/node-server/serve-static';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import { exec } from 'node:child_process';
|
|
12
|
-
import { WebSocketServer } from 'ws';
|
|
13
12
|
import configRoutes from './routes/config.js';
|
|
14
13
|
import agentRoutes, { preloadAgentStatus } from './routes/agents.js';
|
|
15
14
|
import sessionRoutes from './routes/sessions.js';
|
|
@@ -19,67 +18,15 @@ import modelsRoutes from './routes/models.js';
|
|
|
19
18
|
import localModelsRoutes from './routes/local-models.js';
|
|
20
19
|
import { runtime } from './runtime.js';
|
|
21
20
|
import { registerProcessRuntime } from '../core/process-control.js';
|
|
21
|
+
import { mountPikichannel } from '../pikichannel/server.js';
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
// Constants
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
const DASHBOARD_PORT_RETRY_LIMIT = 10;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
httpServer.on('upgrade', (req, socket, head) => {
|
|
31
|
-
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
32
|
-
if (url.pathname !== '/ws') {
|
|
33
|
-
socket.destroy();
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
37
|
-
wss.emit('connection', ws, req);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
wss.on('connection', (ws) => {
|
|
41
|
-
clients.add(ws);
|
|
42
|
-
const keepalive = setInterval(() => {
|
|
43
|
-
if (ws.readyState === ws.OPEN)
|
|
44
|
-
ws.ping();
|
|
45
|
-
}, WS_KEEPALIVE_MS);
|
|
46
|
-
ws.on('message', (raw) => {
|
|
47
|
-
try {
|
|
48
|
-
const msg = JSON.parse(String(raw));
|
|
49
|
-
if (msg?.type === 'ping') {
|
|
50
|
-
ws.send(JSON.stringify({ type: 'pong' }));
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch { /* ignore malformed messages */ }
|
|
54
|
-
});
|
|
55
|
-
ws.on('close', () => {
|
|
56
|
-
clients.delete(ws);
|
|
57
|
-
clearInterval(keepalive);
|
|
58
|
-
});
|
|
59
|
-
ws.on('error', () => {
|
|
60
|
-
clients.delete(ws);
|
|
61
|
-
clearInterval(keepalive);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
const onEvent = (event) => {
|
|
65
|
-
const data = JSON.stringify(event);
|
|
66
|
-
for (const ws of clients) {
|
|
67
|
-
if (ws.readyState === ws.OPEN) {
|
|
68
|
-
ws.send(data);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
runtime.events.on('dashboard-event', onEvent);
|
|
73
|
-
const closeAllClients = () => {
|
|
74
|
-
runtime.events.off('dashboard-event', onEvent);
|
|
75
|
-
for (const ws of clients)
|
|
76
|
-
ws.close();
|
|
77
|
-
clients.clear();
|
|
78
|
-
wss.close();
|
|
79
|
-
};
|
|
80
|
-
httpServer.on('close', closeAllClients);
|
|
81
|
-
return { closeAllClients };
|
|
82
|
-
}
|
|
26
|
+
// The dashboard's live push transport is pikichannel (`/pikichannel/ws`) — the
|
|
27
|
+
// same universal channel mobile/web clients use. There is no separate dashboard
|
|
28
|
+
// WebSocket layer anymore; the pikichannel adapter subscribes to the runtime
|
|
29
|
+
// 'dashboard-event' bus directly, so the SPA and remote clients share one stack.
|
|
83
30
|
// ---------------------------------------------------------------------------
|
|
84
31
|
// Server
|
|
85
32
|
// ---------------------------------------------------------------------------
|
|
@@ -103,6 +50,17 @@ export async function startDashboard(opts = {}) {
|
|
|
103
50
|
app.route('/', cliRoutes);
|
|
104
51
|
app.route('/', modelsRoutes);
|
|
105
52
|
app.route('/', localModelsRoutes);
|
|
53
|
+
// -- pikichannel: pluggable agent-session transport (WebSocket + WebRTC) --
|
|
54
|
+
// Mounted BEFORE the static catch-all so its routes (demo page, browser SDK,
|
|
55
|
+
// status) win. Returns a handle whose upgrade router is attached per HTTP
|
|
56
|
+
// server below. Failure here must not block the dashboard, so it is guarded.
|
|
57
|
+
let pikichannel = null;
|
|
58
|
+
try {
|
|
59
|
+
pikichannel = await mountPikichannel(app);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
runtime.warn(`[pikichannel] mount failed: ${err?.message || err}`);
|
|
63
|
+
}
|
|
106
64
|
// -- Static files: serve dashboard build output --
|
|
107
65
|
// Resolve path relative to this file's location (src/ or dist/)
|
|
108
66
|
const dashboardRoot = path.resolve(import.meta.dirname, '..', '..', 'dashboard', 'dist');
|
|
@@ -149,7 +107,6 @@ export async function startDashboard(opts = {}) {
|
|
|
149
107
|
});
|
|
150
108
|
// -- Process runtime registration --
|
|
151
109
|
let nodeServer = null;
|
|
152
|
-
let wsHandle = null;
|
|
153
110
|
const RESTART_CLOSE_TIMEOUT_MS = 3000;
|
|
154
111
|
const unregisterProcessRuntime = registerProcessRuntime({
|
|
155
112
|
label: 'dashboard',
|
|
@@ -158,9 +115,9 @@ export async function startDashboard(opts = {}) {
|
|
|
158
115
|
resolve();
|
|
159
116
|
return;
|
|
160
117
|
}
|
|
161
|
-
//
|
|
162
|
-
// waiting for persistent connections to end.
|
|
163
|
-
|
|
118
|
+
// Tear down pikichannel peers first — otherwise server.close() hangs
|
|
119
|
+
// waiting for the persistent WebSocket/datachannel connections to end.
|
|
120
|
+
pikichannel?.stop();
|
|
164
121
|
const timer = setTimeout(resolve, RESTART_CLOSE_TIMEOUT_MS);
|
|
165
122
|
nodeServer.close(() => { clearTimeout(timer); resolve(); });
|
|
166
123
|
}),
|
|
@@ -181,8 +138,14 @@ export async function startDashboard(opts = {}) {
|
|
|
181
138
|
// before Hono's request listener consumes the connection.
|
|
182
139
|
const requestListener = getRequestListener(app.fetch);
|
|
183
140
|
const server = http.createServer(requestListener);
|
|
184
|
-
//
|
|
185
|
-
|
|
141
|
+
// Single WebSocket-upgrade dispatcher (attached before listening so no
|
|
142
|
+
// upgrade is missed): pikichannel owns `/pikichannel/*` — its WS data
|
|
143
|
+
// channel and WebRTC signaling. Anything else is rejected.
|
|
144
|
+
server.on('upgrade', (req, socket, head) => {
|
|
145
|
+
if (pikichannel?.handleUpgrade(req, socket, head))
|
|
146
|
+
return;
|
|
147
|
+
socket.destroy();
|
|
148
|
+
});
|
|
186
149
|
server.listen(port, () => {
|
|
187
150
|
if (settled)
|
|
188
151
|
return;
|
|
@@ -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
|
+
}
|