pikiloom 0.4.37 → 0.4.39

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 (67) hide show
  1. package/dashboard/dist/assets/{AgentTab-DbFzaIyZ.js → AgentTab-Ds8D6yrl.js} +1 -1
  2. package/dashboard/dist/assets/{ConnectionModal-QNYOWHCU.js → ConnectionModal-Buj2d5L5.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-BGaKHjFT.js → DirBrowser-BTv7rH8E.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-D4Gv9b8z.js → ExtensionsTab-BnGwxS9U.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-DxxoZd84.js → IMAccessTab-DjQnpOmF.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Nm2e93s5.js → Modal-DpuinsE3.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-BwxZmU0U.js → Modals-Bs842H3n.js} +1 -1
  8. package/dashboard/dist/assets/{Select-SGlII0yx.js → Select-Dvia29HZ.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-CVItEgcH.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-BF6lIAYM.js → SystemTab-BzPtwDxq.js} +1 -1
  11. package/dashboard/dist/assets/index-Bthwt6K_.css +1 -0
  12. package/dashboard/dist/assets/{index-DZiAiRNt.js → index-S0NmlDEH.js} +14 -14
  13. package/dashboard/dist/assets/{index-BP8R_bLT.js → index-yZ-iG1qk.js} +2 -2
  14. package/dashboard/dist/assets/{shared-S0kcs5yP.js → shared-p3kZpiD4.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/kernel-bridge.js +207 -0
  17. package/dist/agent/mcp/capabilities.js +39 -0
  18. package/dist/agent/stream.js +19 -1
  19. package/dist/bot/bot.js +4 -13
  20. package/dist/cli/kernel-app.js +115 -0
  21. package/dist/cli/main.js +7 -0
  22. package/package.json +4 -2
  23. package/packages/kernel/README.md +305 -0
  24. package/packages/kernel/dist/contracts/driver.d.ts +95 -0
  25. package/packages/kernel/dist/contracts/driver.js +1 -0
  26. package/packages/kernel/dist/contracts/ports.d.ts +84 -0
  27. package/packages/kernel/dist/contracts/ports.js +1 -0
  28. package/packages/kernel/dist/contracts/surface.d.ts +92 -0
  29. package/packages/kernel/dist/contracts/surface.js +1 -0
  30. package/packages/kernel/dist/drivers/claude.d.ts +34 -0
  31. package/packages/kernel/dist/drivers/claude.js +500 -0
  32. package/packages/kernel/dist/drivers/codex.d.ts +24 -0
  33. package/packages/kernel/dist/drivers/codex.js +415 -0
  34. package/packages/kernel/dist/drivers/echo.d.ts +20 -0
  35. package/packages/kernel/dist/drivers/echo.js +61 -0
  36. package/packages/kernel/dist/drivers/gemini.d.ts +18 -0
  37. package/packages/kernel/dist/drivers/gemini.js +143 -0
  38. package/packages/kernel/dist/drivers/hermes.d.ts +14 -0
  39. package/packages/kernel/dist/drivers/hermes.js +194 -0
  40. package/packages/kernel/dist/drivers/index.d.ts +5 -0
  41. package/packages/kernel/dist/drivers/index.js +5 -0
  42. package/packages/kernel/dist/index.d.ts +18 -0
  43. package/packages/kernel/dist/index.js +29 -0
  44. package/packages/kernel/dist/ports/defaults.d.ts +59 -0
  45. package/packages/kernel/dist/ports/defaults.js +137 -0
  46. package/packages/kernel/dist/protocol/index.d.ts +309 -0
  47. package/packages/kernel/dist/protocol/index.js +59 -0
  48. package/packages/kernel/dist/runtime/hub.d.ts +68 -0
  49. package/packages/kernel/dist/runtime/hub.js +334 -0
  50. package/packages/kernel/dist/runtime/loom.d.ts +45 -0
  51. package/packages/kernel/dist/runtime/loom.js +72 -0
  52. package/packages/kernel/dist/runtime/pty.d.ts +23 -0
  53. package/packages/kernel/dist/runtime/pty.js +69 -0
  54. package/packages/kernel/dist/runtime/session-runner.d.ts +27 -0
  55. package/packages/kernel/dist/runtime/session-runner.js +210 -0
  56. package/packages/kernel/dist/runtime/tui.d.ts +8 -0
  57. package/packages/kernel/dist/runtime/tui.js +35 -0
  58. package/packages/kernel/dist/runtime/turn.d.ts +17 -0
  59. package/packages/kernel/dist/runtime/turn.js +16 -0
  60. package/packages/kernel/dist/surfaces/cli.d.ts +19 -0
  61. package/packages/kernel/dist/surfaces/cli.js +65 -0
  62. package/packages/kernel/dist/surfaces/index.d.ts +2 -0
  63. package/packages/kernel/dist/surfaces/index.js +2 -0
  64. package/packages/kernel/dist/surfaces/web.d.ts +39 -0
  65. package/packages/kernel/dist/surfaces/web.js +244 -0
  66. package/dashboard/dist/assets/SessionPanel-DYfSlreh.js +0 -1
  67. package/dashboard/dist/assets/index-CtS48Jn-.css +0 -1
@@ -0,0 +1,210 @@
1
+ // Drives ONE turn of ONE session: applies driver events into an accumulating
2
+ // UniversalSnapshot and owns the single-session control verbs (stop/steer/interact).
3
+ // Knows nothing about chat ids, queues, or transports.
4
+ export class SessionRunner {
5
+ sessionKey;
6
+ agent;
7
+ taskId;
8
+ onUpdate;
9
+ interactionHandler;
10
+ snapshot;
11
+ seq = 0;
12
+ abort = new AbortController();
13
+ steerFn = null;
14
+ pending = null;
15
+ finished = false;
16
+ constructor(sessionKey, agent, taskId, onUpdate, interactionHandler) {
17
+ this.sessionKey = sessionKey;
18
+ this.agent = agent;
19
+ this.taskId = taskId;
20
+ this.onUpdate = onUpdate;
21
+ this.interactionHandler = interactionHandler;
22
+ this.snapshot = {
23
+ phase: 'queued', taskId, agent, sessionId: null,
24
+ text: '', reasoning: '', activity: '',
25
+ toolCalls: [], interactions: [], artifacts: [],
26
+ updatedAt: Date.now(),
27
+ };
28
+ }
29
+ get currentSeq() { return this.seq; }
30
+ publish() {
31
+ this.snapshot.updatedAt = Date.now();
32
+ this.seq += 1;
33
+ this.onUpdate(this.snapshot, this.seq);
34
+ }
35
+ async run(driver, input, prompt, model, effort) {
36
+ this.snapshot.phase = 'streaming';
37
+ this.snapshot.prompt = prompt;
38
+ this.snapshot.model = model;
39
+ this.snapshot.effort = effort;
40
+ this.snapshot.startedAt = Date.now();
41
+ this.publish();
42
+ const ctx = {
43
+ signal: this.abort.signal,
44
+ emit: (e) => this.applyEvent(e),
45
+ askUser: (interaction) => this.askUser(interaction),
46
+ registerSteer: (fn) => { this.steerFn = fn; },
47
+ };
48
+ let result;
49
+ try {
50
+ result = await driver.run(input, ctx);
51
+ }
52
+ catch (err) {
53
+ result = { ok: false, text: this.snapshot.text || '', error: err?.message || String(err), stopReason: 'error' };
54
+ }
55
+ // Merge terminal result into the snapshot (driver may have only streamed deltas).
56
+ if (result.text && result.text.trim() && result.text.length >= (this.snapshot.text || '').length) {
57
+ this.snapshot.text = result.text;
58
+ }
59
+ if (result.reasoning && !(this.snapshot.reasoning || '').trim())
60
+ this.snapshot.reasoning = result.reasoning;
61
+ if (result.sessionId)
62
+ this.snapshot.sessionId = result.sessionId;
63
+ if (result.usage)
64
+ this.snapshot.usage = result.usage;
65
+ this.snapshot.error = result.error ?? null;
66
+ this.snapshot.incomplete = !result.ok;
67
+ this.snapshot.phase = 'done';
68
+ this.snapshot.interactions = [];
69
+ this.finished = true;
70
+ this.flushPending({});
71
+ this.publish();
72
+ return result;
73
+ }
74
+ applyEvent(e) {
75
+ switch (e.type) {
76
+ case 'session':
77
+ this.snapshot.sessionId = e.sessionId;
78
+ break;
79
+ case 'text':
80
+ this.snapshot.text = (this.snapshot.text || '') + e.delta;
81
+ break;
82
+ case 'reasoning':
83
+ this.snapshot.reasoning = (this.snapshot.reasoning || '') + e.delta;
84
+ break;
85
+ case 'activity':
86
+ this.snapshot.activity = e.line;
87
+ break;
88
+ case 'plan':
89
+ this.snapshot.plan = e.plan;
90
+ break;
91
+ case 'artifact':
92
+ (this.snapshot.artifacts ||= []).push(e.artifact);
93
+ break;
94
+ case 'usage':
95
+ this.snapshot.usage = mergeUsage(this.snapshot.usage, e.usage);
96
+ break;
97
+ // toolCalls is the structured SSOT; activity is its derived human-readable view.
98
+ // A driver that streams explicit `activity` lines (e.g. echo) owns activity directly;
99
+ // any driver that emits structured tool/subagent events gets the projection for free.
100
+ case 'tool':
101
+ upsertTool(this.snapshot, e.call);
102
+ this.snapshot.activity = projectActivity(this.snapshot);
103
+ break;
104
+ case 'subagent':
105
+ upsertSubAgent(this.snapshot, e.subagent);
106
+ this.snapshot.activity = projectActivity(this.snapshot);
107
+ break;
108
+ }
109
+ this.publish();
110
+ }
111
+ askUser(interaction) {
112
+ if (this.finished || this.abort.signal.aborted)
113
+ return Promise.resolve({});
114
+ (this.snapshot.interactions ||= []).push(interaction);
115
+ this.publish();
116
+ return new Promise((resolve) => {
117
+ let settled = false;
118
+ const finish = (answers) => {
119
+ if (settled)
120
+ return;
121
+ settled = true;
122
+ this.pending = null;
123
+ this.snapshot.interactions = (this.snapshot.interactions || []).filter(i => i.promptId !== interaction.promptId);
124
+ this.publish();
125
+ resolve(answers);
126
+ };
127
+ this.pending = { promptId: interaction.promptId, resolve: finish };
128
+ // A programmatic resolver may answer (or auto-cancel) the interaction; returning
129
+ // null defers to a terminal calling interact(). The default handler defers.
130
+ Promise.resolve(this.interactionHandler?.askUser(interaction) ?? null)
131
+ .then((answers) => { if (answers)
132
+ finish(answers); })
133
+ .catch(() => { });
134
+ });
135
+ }
136
+ flushPending(answers) {
137
+ this.pending?.resolve(answers);
138
+ }
139
+ // ---- control verbs ----
140
+ stop() {
141
+ this.abort.abort();
142
+ this.flushPending({});
143
+ }
144
+ async steer(prompt, attachments) {
145
+ if (this.finished || !this.steerFn)
146
+ return false;
147
+ return this.steerFn(prompt, attachments);
148
+ }
149
+ interact(promptId, action, value) {
150
+ if (!this.pending || this.pending.promptId !== promptId)
151
+ return false;
152
+ const interaction = (this.snapshot.interactions || []).find(i => i.promptId === promptId);
153
+ const q = interaction?.questions[interaction.currentIndex ?? 0] || interaction?.questions[0];
154
+ const qid = q?.id || 'answer';
155
+ const resolve = this.pending.resolve; // resolver does its own cleanup (pending/snapshot/publish)
156
+ if (action === 'cancel')
157
+ resolve({});
158
+ else if (action === 'skip')
159
+ resolve({ [qid]: [] });
160
+ else
161
+ resolve({ [qid]: [value ?? ''] });
162
+ return true;
163
+ }
164
+ }
165
+ // Derive the human-readable activity trail from the structured tool/subagent calls.
166
+ // One line per call, with a status suffix: running -> "summary", done -> "summary done"
167
+ // (or "summary -> detail"), failed -> "summary failed[: detail]". This is the kernel's
168
+ // activity line contract — a lowest-common-denominator view every terminal can render
169
+ // without re-deriving it from toolCalls itself.
170
+ export function projectActivity(snap) {
171
+ const lines = [];
172
+ for (const t of snap.toolCalls || []) {
173
+ if (t.status === 'failed')
174
+ lines.push(t.result ? `${t.summary} failed: ${t.result}` : `${t.summary} failed`);
175
+ else if (t.status === 'done')
176
+ lines.push(t.result ? `${t.summary} -> ${t.result}` : `${t.summary} done`);
177
+ else
178
+ lines.push(t.summary);
179
+ }
180
+ for (const sub of snap.subAgents || []) {
181
+ const label = sub.description || sub.kind || 'subagent';
182
+ if (sub.status === 'failed')
183
+ lines.push(`Run task: ${label} failed`);
184
+ else if (sub.status === 'done')
185
+ lines.push(`Run task: ${label} done`);
186
+ else
187
+ lines.push(`Run task: ${label}`);
188
+ }
189
+ return lines.join('\n');
190
+ }
191
+ function upsertTool(snap, call) {
192
+ const list = (snap.toolCalls ||= []);
193
+ const idx = list.findIndex(t => t.id === call.id);
194
+ if (idx >= 0)
195
+ list[idx] = { ...list[idx], ...call };
196
+ else
197
+ list.push(call);
198
+ }
199
+ function upsertSubAgent(snap, sub) {
200
+ const list = (snap.subAgents ||= []);
201
+ const idx = list.findIndex(s => s.id === sub.id);
202
+ if (idx >= 0)
203
+ list[idx] = { ...list[idx], ...sub };
204
+ else
205
+ list.push(sub);
206
+ }
207
+ function mergeUsage(prev, next) {
208
+ const base = prev || { inputTokens: null, outputTokens: null, cachedInputTokens: null, contextPercent: null };
209
+ return { ...base, ...next };
210
+ }
@@ -0,0 +1,8 @@
1
+ import type { TuiSpec } from '../contracts/driver.js';
2
+ import { type PtyExit } from './pty.js';
3
+ export interface AttachTuiOptions {
4
+ stdin?: NodeJS.ReadStream;
5
+ stdout?: NodeJS.WriteStream;
6
+ observers?: Array<(chunk: string) => void>;
7
+ }
8
+ export declare function attachTui(spec: TuiSpec, opts?: AttachTuiOptions): Promise<PtyExit>;
@@ -0,0 +1,35 @@
1
+ import { PtyBridge } from './pty.js';
2
+ // Full terminal passthrough: spawn the agent's TUI in a PTY and wire the caller's
3
+ // stdin/stdout to it raw (so the user is "inside" the real Claude/Codex TUI), while
4
+ // optionally tee-ing every byte to observers. Resolves with the exit code.
5
+ export async function attachTui(spec, opts = {}) {
6
+ const stdin = opts.stdin ?? process.stdin;
7
+ const stdout = opts.stdout ?? process.stdout;
8
+ const bridge = PtyBridge.open(spec, { cols: stdout.columns, rows: stdout.rows });
9
+ for (const obs of opts.observers ?? [])
10
+ bridge.onData(obs);
11
+ const offData = bridge.onData((d) => { stdout.write(d); });
12
+ const wasRaw = !!stdin.isRaw;
13
+ if (stdin.isTTY)
14
+ stdin.setRawMode(true);
15
+ stdin.resume();
16
+ const onInput = (chunk) => bridge.write(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
17
+ stdin.on('data', onInput);
18
+ const onResize = () => bridge.resize(stdout.columns || 80, stdout.rows || 24);
19
+ stdout.on('resize', onResize);
20
+ return await new Promise((resolve) => {
21
+ bridge.onExit((e) => {
22
+ stdin.off('data', onInput);
23
+ stdout.off('resize', onResize);
24
+ offData();
25
+ if (stdin.isTTY) {
26
+ try {
27
+ stdin.setRawMode(wasRaw);
28
+ }
29
+ catch { /* ignore */ }
30
+ }
31
+ stdin.pause();
32
+ resolve(e);
33
+ });
34
+ });
35
+ }
@@ -0,0 +1,17 @@
1
+ import type { UniversalSnapshot } from '../protocol/index.js';
2
+ import type { AgentDriver, AgentTurnInput, DriverResult } from '../contracts/driver.js';
3
+ import type { InteractionHandler } from '../contracts/ports.js';
4
+ export interface RunTurnOptions {
5
+ prompt?: string;
6
+ model?: string | null;
7
+ effort?: string | null;
8
+ onSnapshot?: (snapshot: UniversalSnapshot) => void;
9
+ onSteer?: (steer: (prompt: string, attachments?: string[]) => Promise<boolean>) => void;
10
+ signal?: AbortSignal;
11
+ interactionHandler?: InteractionHandler;
12
+ }
13
+ export interface TurnOutcome {
14
+ result: DriverResult;
15
+ snapshot: UniversalSnapshot;
16
+ }
17
+ export declare function runTurn(driver: AgentDriver, input: AgentTurnInput, opts?: RunTurnOptions): Promise<TurnOutcome>;
@@ -0,0 +1,16 @@
1
+ import { SessionRunner } from './session-runner.js';
2
+ import { AutoCancelInteractionHandler } from '../ports/defaults.js';
3
+ export async function runTurn(driver, input, opts = {}) {
4
+ const handler = opts.interactionHandler ?? new AutoCancelInteractionHandler();
5
+ const runner = new SessionRunner('turn', driver.id, 'turn', (snap) => { opts.onSnapshot?.(snap); }, handler);
6
+ if (opts.onSteer)
7
+ opts.onSteer((p, a) => runner.steer(p, a));
8
+ if (opts.signal) {
9
+ if (opts.signal.aborted)
10
+ runner.stop();
11
+ else
12
+ opts.signal.addEventListener('abort', () => runner.stop(), { once: true });
13
+ }
14
+ const result = await runner.run(driver, input, opts.prompt ?? input.prompt, opts.model ?? input.model ?? null, opts.effort ?? input.effort ?? null);
15
+ return { result, snapshot: runner.snapshot };
16
+ }
@@ -0,0 +1,19 @@
1
+ import type { LoomIO, Surface } from '../contracts/surface.js';
2
+ export declare class CliSurface implements Surface {
3
+ private readonly opts;
4
+ readonly id = "cli";
5
+ readonly capabilities: {
6
+ editMessages: boolean;
7
+ images: boolean;
8
+ buttons: boolean;
9
+ };
10
+ private rl?;
11
+ private unsub?;
12
+ constructor(opts?: {
13
+ agent?: string;
14
+ input?: NodeJS.ReadableStream;
15
+ output?: NodeJS.WritableStream;
16
+ });
17
+ start(io: LoomIO): Promise<void>;
18
+ stop(): Promise<void>;
19
+ }
@@ -0,0 +1,65 @@
1
+ import readline from 'node:readline';
2
+ // Simplest possible terminal: stdin lines -> prompts, snapshot text -> stdout.
3
+ // Demonstrates that IM/Web/CLI are all just Terminals over the same LoomIO.
4
+ export class CliSurface {
5
+ opts;
6
+ id = 'cli';
7
+ capabilities = { editMessages: false, images: false, buttons: false };
8
+ rl;
9
+ unsub;
10
+ constructor(opts = {}) {
11
+ this.opts = opts;
12
+ }
13
+ async start(io) {
14
+ const out = this.opts.output || process.stdout;
15
+ let sessionKey; // conversation continuity across lines
16
+ let pendingPromptId = null; // a HITL question awaiting an answer from this terminal
17
+ let lastQueued = 0;
18
+ this.unsub = io.subscribe((key, snap) => {
19
+ if (sessionKey && key !== sessionKey)
20
+ return; // single-conversation terminal
21
+ const interaction = (snap.interactions || [])[0];
22
+ if (interaction && interaction.promptId !== pendingPromptId && snap.phase !== 'done') {
23
+ pendingPromptId = interaction.promptId; // surface HITL; next line answers it
24
+ const q = interaction.questions[interaction.currentIndex ?? 0] || interaction.questions[0];
25
+ out.write(`\n[?] ${q?.text || interaction.title}\n> `);
26
+ return;
27
+ }
28
+ const q = snap.queued?.length ?? 0;
29
+ if (q !== lastQueued && snap.phase !== 'done') {
30
+ lastQueued = q;
31
+ if (q)
32
+ out.write(`\n[queued: ${q}]\n> `);
33
+ }
34
+ if (snap.phase !== 'done')
35
+ return;
36
+ pendingPromptId = null;
37
+ lastQueued = 0;
38
+ out.write(`\n${snap.text || snap.error || '(no output)'}\n> `);
39
+ });
40
+ this.rl = readline.createInterface({ input: this.opts.input || process.stdin, output: out, prompt: '> ' });
41
+ this.rl.prompt();
42
+ this.rl.on('line', (line) => {
43
+ const text = line.trim();
44
+ if (!text) {
45
+ this.rl.prompt();
46
+ return;
47
+ }
48
+ if (pendingPromptId) {
49
+ const pid = pendingPromptId;
50
+ pendingPromptId = null;
51
+ io.interact(pid, 'text', text);
52
+ return;
53
+ } // HITL answer
54
+ if (text === '/models') { // discovery from the terminal (C2)
55
+ void io.listModels(this.opts.agent || io.listAgents()[0] || '').then(ms => out.write(`\n${ms.map(m => m.id).join(', ') || '(no models)'}\n> `));
56
+ return;
57
+ }
58
+ void io.prompt({ prompt: text, agent: this.opts.agent, sessionKey }).then(r => { sessionKey = r.sessionKey; }); // continue the conversation
59
+ });
60
+ }
61
+ async stop() {
62
+ this.unsub?.();
63
+ this.rl?.close();
64
+ }
65
+ }
@@ -0,0 +1,2 @@
1
+ export { WebSurface, type WebSurfaceOptions } from './web.js';
2
+ export { CliSurface } from './cli.js';
@@ -0,0 +1,2 @@
1
+ export { WebSurface } from './web.js';
2
+ export { CliSurface } from './cli.js';
@@ -0,0 +1,39 @@
1
+ import type http from 'node:http';
2
+ import type { LoomIO, Surface, SurfaceCapabilities, TuiHost } from '../contracts/surface.js';
3
+ export interface WebSurfaceOptions {
4
+ port?: number;
5
+ server?: http.Server;
6
+ token?: string;
7
+ name?: string;
8
+ allowTui?: boolean;
9
+ access?: {
10
+ prompt?: boolean;
11
+ tui?: boolean;
12
+ tuiReadonly?: boolean;
13
+ };
14
+ }
15
+ export declare class WebSurface implements Surface {
16
+ private readonly opts;
17
+ readonly id = "web";
18
+ readonly capabilities: SurfaceCapabilities;
19
+ private wss?;
20
+ private io?;
21
+ private host?;
22
+ private unsub?;
23
+ private unsubSessions?;
24
+ private readonly peers;
25
+ constructor(opts?: WebSurfaceOptions);
26
+ get port(): number | null;
27
+ start(io: LoomIO, host?: TuiHost): Promise<void>;
28
+ stop(): Promise<void>;
29
+ private get tuiEnabled();
30
+ private get promptEnabled();
31
+ private killPeerPtys;
32
+ private hostInfo;
33
+ private onConnection;
34
+ private handle;
35
+ private sendFull;
36
+ private broadcastPatch;
37
+ private broadcast;
38
+ private send;
39
+ }
@@ -0,0 +1,244 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import { PROTOCOL_VERSION, isClientMessage, } from '../protocol/index.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ const SUBSCRIBE_ALL = '*';
5
+ // The built-in Web/remote terminal: a ws host speaking the UniversalSnapshot wire
6
+ // protocol over LoomIO. Any pikichannel client (incl. the existing pikiloom dashboard
7
+ // SPA) can connect: hello -> subscribe -> receive `session` patches -> prompt/stop/steer.
8
+ export class WebSurface {
9
+ opts;
10
+ id = 'web';
11
+ capabilities = { tunnel: true, images: true, buttons: true, editMessages: true };
12
+ wss;
13
+ io;
14
+ host;
15
+ unsub;
16
+ unsubSessions;
17
+ peers = new Set();
18
+ constructor(opts = {}) {
19
+ this.opts = opts;
20
+ }
21
+ get port() {
22
+ const a = this.wss?.address();
23
+ return a && typeof a === 'object' ? a.port : null;
24
+ }
25
+ async start(io, host) {
26
+ this.io = io;
27
+ this.host = host;
28
+ this.wss = this.opts.server
29
+ ? new WebSocketServer({ server: this.opts.server })
30
+ : new WebSocketServer({ port: this.opts.port ?? 0 });
31
+ if (!this.opts.server) {
32
+ await new Promise((resolve, reject) => {
33
+ this.wss.once('listening', resolve);
34
+ this.wss.once('error', reject);
35
+ });
36
+ }
37
+ this.wss.on('connection', (ws) => this.onConnection(ws));
38
+ this.unsub = io.subscribe((key, _snap, patch, seq) => this.broadcastPatch(key, patch, seq));
39
+ this.unsubSessions = io.onSessionsChanged((sessions) => this.broadcast({ type: 'sessions', sessions }, true));
40
+ }
41
+ async stop() {
42
+ this.unsub?.();
43
+ this.unsubSessions?.();
44
+ for (const p of this.peers) {
45
+ this.killPeerPtys(p);
46
+ try {
47
+ p.ws.close();
48
+ }
49
+ catch { /* ignore */ }
50
+ }
51
+ this.peers.clear();
52
+ await new Promise((resolve) => { if (this.wss)
53
+ this.wss.close(() => resolve());
54
+ else
55
+ resolve(); });
56
+ }
57
+ get tuiEnabled() { return !!this.host && this.opts.allowTui !== false && this.opts.access?.tui !== false; }
58
+ get promptEnabled() { return this.opts.access?.prompt !== false; }
59
+ killPeerPtys(peer) {
60
+ for (const b of peer.ptys.values()) {
61
+ try {
62
+ b.kill();
63
+ }
64
+ catch { /* ignore */ }
65
+ }
66
+ peer.ptys.clear();
67
+ }
68
+ hostInfo() {
69
+ const capabilities = ['subscribe-all', 'artifacts', 'history', 'catalog'];
70
+ if (this.promptEnabled)
71
+ capabilities.push('prompt', 'stop', 'steer', 'interact');
72
+ if (this.tuiEnabled)
73
+ capabilities.push('tui');
74
+ return { name: this.opts.name || 'loom', version: '0.1.0', transport: 'websocket', capabilities, authRequired: !!this.opts.token };
75
+ }
76
+ onConnection(ws) {
77
+ const peer = { ws, subs: new Set(), authed: !this.opts.token, ptys: new Map() };
78
+ this.peers.add(peer);
79
+ ws.on('message', (data) => {
80
+ let msg;
81
+ try {
82
+ msg = JSON.parse(data.toString());
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (!isClientMessage(msg))
88
+ return;
89
+ this.handle(peer, msg).catch((err) => this.send(peer, { type: 'error', message: err?.message || String(err) }));
90
+ });
91
+ const cleanup = () => { this.killPeerPtys(peer); this.peers.delete(peer); };
92
+ ws.on('close', cleanup);
93
+ ws.on('error', cleanup);
94
+ }
95
+ async handle(peer, msg) {
96
+ if (msg.type === 'ping') {
97
+ this.send(peer, { type: 'pong', t: msg.t });
98
+ return;
99
+ }
100
+ if (!peer.authed) {
101
+ if (msg.type !== 'hello') {
102
+ this.send(peer, { type: 'error', message: 'not authenticated', code: 'auth' });
103
+ return;
104
+ }
105
+ if (this.opts.token && msg.token !== this.opts.token) {
106
+ this.send(peer, { type: 'error', message: 'unauthorized', code: 'auth' });
107
+ peer.ws.close();
108
+ return;
109
+ }
110
+ peer.authed = true;
111
+ }
112
+ if (msg.type === 'hello') {
113
+ this.send(peer, { type: 'welcome', v: PROTOCOL_VERSION, host: this.hostInfo(), sessions: this.io.listSessions() });
114
+ return;
115
+ }
116
+ switch (msg.type) {
117
+ case 'subscribe': {
118
+ peer.subs.add(msg.sessionKey);
119
+ if (msg.sessionKey === SUBSCRIBE_ALL)
120
+ for (const m of this.io.listSessions())
121
+ this.sendFull(peer, m.sessionKey);
122
+ else
123
+ this.sendFull(peer, msg.sessionKey);
124
+ return;
125
+ }
126
+ case 'unsubscribe':
127
+ peer.subs.delete(msg.sessionKey);
128
+ return;
129
+ case 'getSnapshot':
130
+ this.sendFull(peer, msg.sessionKey);
131
+ return;
132
+ case 'listSessions':
133
+ this.send(peer, { type: 'sessions', sessions: this.io.listSessions() });
134
+ return;
135
+ case 'getHistory': {
136
+ const turns = await this.io.getHistory(msg.sessionKey);
137
+ this.send(peer, { type: 'history', sessionKey: msg.sessionKey, turns, ref: msg.ref });
138
+ return;
139
+ }
140
+ case 'getCatalog': {
141
+ const agent = msg.agent;
142
+ const agents = this.io.listAgentInfo();
143
+ const models = agent ? await this.io.listModels(agent) : [];
144
+ const effort = agent ? await this.io.listEffort(agent, msg.model ?? null) : [];
145
+ const tools = agent ? await this.io.listTools(agent, msg.workdir) : [];
146
+ const skills = agent ? await this.io.listSkills(agent, msg.workdir) : [];
147
+ this.send(peer, { type: 'catalog', agents, agent, models, effort, tools, skills, ref: msg.ref });
148
+ return;
149
+ }
150
+ case 'prompt': {
151
+ if (!this.promptEnabled) {
152
+ this.send(peer, { type: 'error', message: 'prompt not permitted (read-only)', code: 'access', clientRef: msg.clientRef });
153
+ return;
154
+ }
155
+ const res = await this.io.prompt({
156
+ sessionKey: msg.sessionKey, prompt: msg.prompt, agent: msg.agent, workdir: msg.workdir,
157
+ model: msg.model, effort: msg.effort, attachments: msg.attachments,
158
+ }).catch((e) => ({ error: e?.message || 'prompt failed' }));
159
+ if (res.error) {
160
+ this.send(peer, { type: 'error', message: res.error, clientRef: msg.clientRef });
161
+ return;
162
+ }
163
+ peer.subs.add(res.sessionKey);
164
+ this.send(peer, { type: 'accepted', sessionKey: res.sessionKey, taskId: res.taskId, clientRef: msg.clientRef });
165
+ this.sendFull(peer, res.sessionKey);
166
+ return;
167
+ }
168
+ case 'stop':
169
+ this.io.stop(msg.sessionKey);
170
+ return;
171
+ case 'steer':
172
+ await this.io.steer(msg.taskId, msg.prompt);
173
+ return;
174
+ case 'interact':
175
+ this.io.interact(msg.promptId, msg.action, msg.value);
176
+ return;
177
+ // ---- Lane R: raw PTY (TUI passthrough) ----
178
+ case 'openTui': {
179
+ if (!this.tuiEnabled || !this.host) {
180
+ this.send(peer, { type: 'error', message: 'TUI not available', code: 'tui', clientRef: msg.ref });
181
+ return;
182
+ }
183
+ let bridge;
184
+ try {
185
+ bridge = await this.host.openTui({ agent: msg.agent, workdir: msg.workdir, model: msg.model, sessionId: msg.sessionId, cols: msg.cols, rows: msg.rows });
186
+ }
187
+ catch (e) {
188
+ this.send(peer, { type: 'error', message: e?.message || 'openTui failed', code: 'tui', clientRef: msg.ref });
189
+ return;
190
+ }
191
+ const tuiId = randomUUID();
192
+ peer.ptys.set(tuiId, bridge);
193
+ bridge.onData((data) => this.send(peer, { type: 'tuiData', tuiId, data }));
194
+ bridge.onExit((e) => { peer.ptys.delete(tuiId); this.send(peer, { type: 'tuiExit', tuiId, exitCode: e.exitCode, signal: e.signal }); });
195
+ this.send(peer, { type: 'tuiOpened', tuiId, ref: msg.ref });
196
+ return;
197
+ }
198
+ case 'tuiInput':
199
+ if (!this.opts.access?.tuiReadonly)
200
+ peer.ptys.get(msg.tuiId)?.write(msg.data);
201
+ return; // readonly = spectator
202
+ case 'tuiResize':
203
+ peer.ptys.get(msg.tuiId)?.resize(msg.cols, msg.rows);
204
+ return;
205
+ case 'tuiClose': {
206
+ const b = peer.ptys.get(msg.tuiId);
207
+ if (b) {
208
+ peer.ptys.delete(msg.tuiId);
209
+ try {
210
+ b.kill();
211
+ }
212
+ catch { /* ignore */ }
213
+ }
214
+ return;
215
+ }
216
+ }
217
+ }
218
+ sendFull(peer, sessionKey) {
219
+ const snap = this.io.getSnapshot(sessionKey);
220
+ if (!snap)
221
+ return;
222
+ this.send(peer, { type: 'session', sessionKey, seq: snap.seq, patch: { full: snap.snapshot } });
223
+ }
224
+ broadcastPatch(sessionKey, patch, seq) {
225
+ const frame = { type: 'session', sessionKey, seq, patch };
226
+ for (const peer of this.peers) {
227
+ if (peer.authed && (peer.subs.has(SUBSCRIBE_ALL) || peer.subs.has(sessionKey)))
228
+ this.send(peer, frame);
229
+ }
230
+ }
231
+ broadcast(frame, authedOnly = false) {
232
+ for (const peer of this.peers)
233
+ if (!authedOnly || peer.authed)
234
+ this.send(peer, frame);
235
+ }
236
+ send(peer, frame) {
237
+ if (peer.ws.readyState !== WebSocket.OPEN)
238
+ return;
239
+ try {
240
+ peer.ws.send(JSON.stringify(frame));
241
+ }
242
+ catch { /* ignore */ }
243
+ }
244
+ }