@spectrum-ts/terminal 5.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License Copyright (c) 2025 Photon AI
2
+
3
+ Permission is hereby granted,
4
+ free of charge, to any person obtaining a copy of this software and associated
5
+ documentation files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use, copy, modify, merge,
7
+ publish, distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice
12
+ (including the next paragraph) shall be included in all copies or substantial
13
+ portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @spectrum-ts/terminal
2
+
3
+ Terminal provider for [spectrum-ts](https://github.com/photon-hq/spectrum-ts) — chat with your agent from the command line via the standalone [tuichat](https://github.com/photon-hq/tuichat) binary (auto-downloaded on first use).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add spectrum-ts @spectrum-ts/terminal
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```ts
14
+ import { Spectrum } from "spectrum-ts";
15
+ import { terminal } from "@spectrum-ts/terminal";
16
+
17
+ const spectrum = Spectrum({ providers: [terminal] });
18
+ ```
19
+
20
+ See the [spectrum-ts documentation](https://photon.codes/spectrum) for the full guide.
@@ -0,0 +1,120 @@
1
+ import { ChildProcess } from "node:child_process";
2
+ import { Socket } from "node:net";
3
+ import { Content } from "@spectrum-ts/core";
4
+ import z from "zod";
5
+
6
+ //#region src/protocol.d.ts
7
+ type ProtocolContent = {
8
+ type: "text";
9
+ text: string;
10
+ } | {
11
+ type: "attachment";
12
+ name: string;
13
+ mimeType: string;
14
+ size?: number;
15
+ bytes?: string;
16
+ path?: string;
17
+ } | {
18
+ type: "voice";
19
+ name?: string;
20
+ mimeType: string;
21
+ size?: number;
22
+ bytes?: string;
23
+ path?: string;
24
+ } | {
25
+ type: "contact";
26
+ name?: {
27
+ formatted?: string;
28
+ first?: string;
29
+ last?: string;
30
+ };
31
+ vcard?: string;
32
+ } | {
33
+ type: "custom";
34
+ raw: unknown;
35
+ };
36
+ interface ProtocolMessageNotification {
37
+ content: ProtocolContent;
38
+ id: string;
39
+ replyTo?: {
40
+ messageId: string;
41
+ };
42
+ senderId: string;
43
+ spaceId: string;
44
+ timestamp: string;
45
+ }
46
+ interface ProtocolReactionNotification {
47
+ messageId: string;
48
+ reaction: string;
49
+ senderId: string;
50
+ spaceId: string;
51
+ timestamp: string;
52
+ }
53
+ declare class RpcSession {
54
+ private readonly decoder;
55
+ private nextId;
56
+ private readonly pending;
57
+ private onNotify;
58
+ private onClose;
59
+ private closed;
60
+ private readonly socket;
61
+ constructor(socket: Socket);
62
+ handleNotifications(h: (method: string, params: unknown) => void): void;
63
+ onClosed(h: () => void): void;
64
+ request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
65
+ notify(method: string, params?: unknown): void;
66
+ close(): void;
67
+ private handle;
68
+ private shutdown;
69
+ }
70
+ //#endregion
71
+ //#region src/index.d.ts
72
+ interface ConsoleHijack {
73
+ restore: () => void;
74
+ }
75
+ type InboundEvent = {
76
+ kind: "message";
77
+ value: ProtocolMessageNotification;
78
+ } | {
79
+ kind: "reaction";
80
+ value: ProtocolReactionNotification;
81
+ };
82
+ interface TerminalClient {
83
+ events: AsyncIterable<InboundEvent>;
84
+ hijack: ConsoleHijack;
85
+ knownChats: Set<string>;
86
+ nextChatIndex: number;
87
+ proc: ChildProcess;
88
+ session: RpcSession;
89
+ }
90
+ type SpectrumContent = Content;
91
+ interface TerminalInboundMessage {
92
+ content: SpectrumContent;
93
+ id: string;
94
+ replyTo?: {
95
+ messageId: string;
96
+ };
97
+ sender: {
98
+ id: string;
99
+ };
100
+ space: {
101
+ id: string;
102
+ };
103
+ timestamp: Date;
104
+ }
105
+ declare const terminal: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"Terminal", z.ZodObject<{
106
+ commands: z.ZodOptional<z.ZodArray<z.ZodObject<{
107
+ name: z.ZodString;
108
+ description: z.ZodOptional<z.ZodString>;
109
+ }, z.core.$strip>>>;
110
+ }, z.core.$strip>, z.ZodType<object, unknown, z.core.$ZodTypeInternals<object, unknown>> | undefined, z.ZodType<object, unknown, z.core.$ZodTypeInternals<object, unknown>> | undefined, z.ZodType<object, unknown, z.core.$ZodTypeInternals<object, unknown>> | undefined, TerminalClient, {
111
+ id: string;
112
+ }, {
113
+ id: string;
114
+ }, z.ZodObject<{
115
+ replyTo: z.ZodOptional<z.ZodObject<{
116
+ messageId: z.ZodString;
117
+ }, z.core.$strip>>;
118
+ }, z.core.$strip>, TerminalInboundMessage, undefined, Record<never, never>, Record<never, never>, Record<never, never>>> & Readonly<Record<never, never>>;
119
+ //#endregion
120
+ export { terminal };
package/dist/index.js ADDED
@@ -0,0 +1,698 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:net";
3
+ import { inspect } from "node:util";
4
+ import { UnsupportedError, definePlatform, fromVCard, stream, toVCard } from "@spectrum-ts/core";
5
+ import { asAttachment, asContact, asCustom, asVoice, reactionSchema } from "@spectrum-ts/core/authoring";
6
+ import z from "zod";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { chmodSync, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ //#region src/protocol.ts
12
+ const HEADER_TERMINATOR = Buffer.from("\r\n\r\n");
13
+ const CONTENT_LENGTH = "content-length:";
14
+ function encode(message) {
15
+ const body = Buffer.from(JSON.stringify(message), "utf8");
16
+ const header = Buffer.from(`Content-Length: ${body.byteLength}\r\n\r\n`);
17
+ const out = new Uint8Array(header.byteLength + body.byteLength);
18
+ out.set(header, 0);
19
+ out.set(body, header.byteLength);
20
+ return out;
21
+ }
22
+ var Decoder = class {
23
+ buf = Buffer.alloc(0);
24
+ push(chunk) {
25
+ this.buf = this.buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buf, chunk]);
26
+ const out = [];
27
+ for (;;) {
28
+ const msg = this.readOne();
29
+ if (!msg) break;
30
+ out.push(msg);
31
+ }
32
+ return out;
33
+ }
34
+ readOne() {
35
+ const end = this.buf.indexOf(HEADER_TERMINATOR);
36
+ if (end < 0) return null;
37
+ const header = this.buf.subarray(0, end).toString("utf8");
38
+ let len = -1;
39
+ for (const line of header.split("\r\n")) if (line.toLowerCase().startsWith(CONTENT_LENGTH)) {
40
+ const n = Number.parseInt(line.slice(15).trim(), 10);
41
+ if (!Number.isFinite(n) || n < 0) throw new Error("invalid Content-Length");
42
+ len = n;
43
+ }
44
+ if (len < 0) throw new Error("missing Content-Length header");
45
+ const bodyStart = end + HEADER_TERMINATOR.length;
46
+ const bodyEnd = bodyStart + len;
47
+ if (this.buf.length < bodyEnd) return null;
48
+ const body = this.buf.subarray(bodyStart, bodyEnd).toString("utf8");
49
+ this.buf = this.buf.subarray(bodyEnd);
50
+ return JSON.parse(body);
51
+ }
52
+ };
53
+ var RpcSession = class {
54
+ decoder = new Decoder();
55
+ nextId = 1;
56
+ pending = /* @__PURE__ */ new Map();
57
+ onNotify = null;
58
+ onClose = null;
59
+ closed = false;
60
+ socket;
61
+ constructor(socket) {
62
+ this.socket = socket;
63
+ socket.on("data", (chunk) => this.handle(chunk));
64
+ socket.on("close", () => this.shutdown());
65
+ socket.on("error", () => this.shutdown());
66
+ }
67
+ handleNotifications(h) {
68
+ this.onNotify = h;
69
+ }
70
+ onClosed(h) {
71
+ this.onClose = h;
72
+ }
73
+ async request(method, params, timeoutMs) {
74
+ if (this.closed) throw new Error("session closed");
75
+ const id = this.nextId++;
76
+ const msg = {
77
+ jsonrpc: "2.0",
78
+ id,
79
+ method,
80
+ params
81
+ };
82
+ return new Promise((resolve, reject) => {
83
+ let settled = false;
84
+ let timer;
85
+ const done = () => {
86
+ settled = true;
87
+ if (timer) clearTimeout(timer);
88
+ };
89
+ this.pending.set(id, {
90
+ resolve: (v) => {
91
+ if (settled) return;
92
+ done();
93
+ resolve(v);
94
+ },
95
+ reject: (e) => {
96
+ if (settled) return;
97
+ done();
98
+ reject(e);
99
+ }
100
+ });
101
+ if (timeoutMs !== void 0 && timeoutMs >= 0) {
102
+ timer = setTimeout(() => {
103
+ if (settled) return;
104
+ settled = true;
105
+ this.pending.delete(id);
106
+ reject(/* @__PURE__ */ new Error(`rpc ${method} timed out after ${timeoutMs}ms`));
107
+ }, timeoutMs);
108
+ timer.unref?.();
109
+ }
110
+ try {
111
+ this.socket.write(encode(msg));
112
+ } catch (err) {
113
+ if (settled) return;
114
+ done();
115
+ this.pending.delete(id);
116
+ reject(err);
117
+ }
118
+ });
119
+ }
120
+ notify(method, params) {
121
+ if (this.closed) return;
122
+ const msg = {
123
+ jsonrpc: "2.0",
124
+ method,
125
+ params
126
+ };
127
+ try {
128
+ this.socket.write(encode(msg));
129
+ } catch {}
130
+ }
131
+ close() {
132
+ this.shutdown();
133
+ }
134
+ handle(chunk) {
135
+ let msgs;
136
+ try {
137
+ msgs = this.decoder.push(chunk);
138
+ } catch {
139
+ this.shutdown();
140
+ return;
141
+ }
142
+ for (const m of msgs) {
143
+ if ("id" in m && "method" in m) continue;
144
+ if ("id" in m) {
145
+ const p = this.pending.get(m.id);
146
+ if (!p) continue;
147
+ this.pending.delete(m.id);
148
+ if (m.error) p.reject(new Error(m.error.message));
149
+ else p.resolve(m.result);
150
+ } else if ("method" in m) try {
151
+ this.onNotify?.(m.method, m.params);
152
+ } catch {}
153
+ }
154
+ }
155
+ shutdown() {
156
+ if (this.closed) return;
157
+ this.closed = true;
158
+ for (const p of this.pending.values()) p.reject(/* @__PURE__ */ new Error("session closed"));
159
+ this.pending.clear();
160
+ try {
161
+ this.socket.end();
162
+ } catch {}
163
+ try {
164
+ this.socket.destroy();
165
+ } catch {}
166
+ this.onClose?.();
167
+ }
168
+ };
169
+ const REPO = "photon-hq/tuichat";
170
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
171
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
172
+ function targetSuffix() {
173
+ const key = `${process.platform}-${process.arch}`;
174
+ const t = {
175
+ "darwin-arm64": "darwin-arm64",
176
+ "darwin-x64": "darwin-x64",
177
+ "linux-x64": "linux-x64",
178
+ "linux-arm64": "linux-arm64",
179
+ "win32-x64": "windows-x64"
180
+ }[key];
181
+ if (!t) throw new Error(`tuichat: unsupported platform/arch: ${key}`);
182
+ return t;
183
+ }
184
+ function cacheDir(version) {
185
+ if (process.platform === "win32") return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "tuichat", `v${version}`);
186
+ if (process.platform === "darwin") return join(homedir(), "Library", "Caches", "tuichat", `v${version}`);
187
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tuichat", `v${version}`);
188
+ }
189
+ const LINE_SPLIT = /\r?\n/;
190
+ const CHECKSUM_LINE = /^([a-f0-9]{64})\s+\*?(\S+)$/;
191
+ function parseChecksums(text) {
192
+ const out = {};
193
+ for (const line of text.split(LINE_SPLIT)) {
194
+ const m = line.match(CHECKSUM_LINE);
195
+ if (m?.[1] && m[2]) out[m[2]] = m[1];
196
+ }
197
+ return out;
198
+ }
199
+ async function downloadVerified(version, filename) {
200
+ const base = `https://github.com/${REPO}/releases/download/v${version}`;
201
+ const controller = new AbortController();
202
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
203
+ let sumsRes;
204
+ let binRes;
205
+ try {
206
+ [sumsRes, binRes] = await Promise.all([fetch(`${base}/SHA256SUMS`, { signal: controller.signal }), fetch(`${base}/${filename}`, { signal: controller.signal })]);
207
+ } catch (err) {
208
+ if (err instanceof Error && err.name === "AbortError") throw new Error(`tuichat: timed out fetching v${version} release assets after ${DOWNLOAD_TIMEOUT_MS}ms`);
209
+ throw err;
210
+ } finally {
211
+ clearTimeout(timer);
212
+ }
213
+ if (!sumsRes.ok) throw new Error(`tuichat: failed to fetch SHA256SUMS (v${version}): HTTP ${sumsRes.status}`);
214
+ if (!binRes.ok) throw new Error(`tuichat: failed to fetch ${filename} (v${version}): HTTP ${binRes.status}`);
215
+ const expected = parseChecksums(await sumsRes.text())[filename];
216
+ if (!expected) throw new Error(`tuichat: no checksum for ${filename} in SHA256SUMS (v${version})`);
217
+ const bytes = Buffer.from(await binRes.arrayBuffer());
218
+ const actual = createHash("sha256").update(bytes).digest("hex");
219
+ if (actual !== expected) throw new Error(`tuichat: checksum mismatch for ${filename} (expected ${expected}, got ${actual})`);
220
+ return bytes;
221
+ }
222
+ function writeBinary(path, bytes) {
223
+ const tmpPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
224
+ try {
225
+ writeFileSync(tmpPath, bytes);
226
+ if (process.platform !== "win32") chmodSync(tmpPath, 493);
227
+ renameSync(tmpPath, path);
228
+ } catch (err) {
229
+ const renameErr = err;
230
+ try {
231
+ unlinkSync(tmpPath);
232
+ } catch {}
233
+ if (process.platform === "win32" && renameErr.code === "EEXIST" && existsSync(path)) return;
234
+ throw err;
235
+ }
236
+ }
237
+ async function resolveTuichatBinary(options = {}) {
238
+ const override = process.env.TUICHAT_BINARY;
239
+ if (override) {
240
+ if (!existsSync(override)) throw new Error(`tuichat: TUICHAT_BINARY=${override} does not exist`);
241
+ return override;
242
+ }
243
+ const version = options.version ?? process.env.TUICHAT_VERSION ?? "0.1.4";
244
+ if (!VERSION_RE.test(version)) throw new Error(`tuichat: invalid version "${version}" — expected semver like 0.1.4`);
245
+ const target = targetSuffix();
246
+ const filename = `tuichat-${target}${target.startsWith("windows") ? ".exe" : ""}`;
247
+ const dir = cacheDir(version);
248
+ const path = join(dir, filename);
249
+ if (!options.force && existsSync(path)) return path;
250
+ const bytes = await downloadVerified(version, filename);
251
+ mkdirSync(dir, { recursive: true });
252
+ writeBinary(path, bytes);
253
+ return path;
254
+ }
255
+ //#endregion
256
+ //#region src/index.ts
257
+ const SHUTDOWN_TIMEOUT_MS = 2e3;
258
+ const SPAWN_CONNECT_TIMEOUT_MS = 1e4;
259
+ const INITIALIZE_TIMEOUT_MS = 1e4;
260
+ const commandSchema = z.object({
261
+ name: z.string().regex(/^\/[A-Za-z0-9_-]+$/, "command must start with /"),
262
+ description: z.string().optional()
263
+ });
264
+ const LOG_LEVELS = [
265
+ "log",
266
+ "info",
267
+ "warn",
268
+ "error",
269
+ "debug"
270
+ ];
271
+ function installConsoleHijack(session) {
272
+ const originals = {};
273
+ let forwarding = false;
274
+ for (const level of LOG_LEVELS) {
275
+ originals[level] = console[level].bind(console);
276
+ console[level] = (...args) => {
277
+ if (forwarding) {
278
+ originals[level](...args);
279
+ return;
280
+ }
281
+ forwarding = true;
282
+ try {
283
+ const text = args.map((a) => typeof a === "string" ? a : inspect(a, {
284
+ depth: 3,
285
+ colors: false
286
+ })).join(" ");
287
+ session.notify("log", {
288
+ level,
289
+ text
290
+ });
291
+ } finally {
292
+ forwarding = false;
293
+ }
294
+ };
295
+ }
296
+ return { restore: () => {
297
+ for (const level of LOG_LEVELS) console[level] = originals[level];
298
+ } };
299
+ }
300
+ function generateChatId(client) {
301
+ while (client.knownChats.has(`chat-${client.nextChatIndex}`)) client.nextChatIndex += 1;
302
+ const id = `chat-${client.nextChatIndex}`;
303
+ client.nextChatIndex += 1;
304
+ client.knownChats.add(id);
305
+ return id;
306
+ }
307
+ function makeEventQueue() {
308
+ const queue = [];
309
+ const waiters = [];
310
+ let closed = false;
311
+ const drain = () => {
312
+ while (waiters.length > 0) waiters.shift()?.({
313
+ value: void 0,
314
+ done: true
315
+ });
316
+ };
317
+ return {
318
+ iter: { [Symbol.asyncIterator]() {
319
+ return {
320
+ next() {
321
+ if (closed && queue.length === 0) return Promise.resolve({
322
+ value: void 0,
323
+ done: true
324
+ });
325
+ const buffered = queue.shift();
326
+ if (buffered !== void 0) return Promise.resolve({
327
+ value: buffered,
328
+ done: false
329
+ });
330
+ return new Promise((resolve) => waiters.push(resolve));
331
+ },
332
+ return() {
333
+ closed = true;
334
+ drain();
335
+ return Promise.resolve({
336
+ value: void 0,
337
+ done: true
338
+ });
339
+ }
340
+ };
341
+ } },
342
+ push(v) {
343
+ if (closed) return;
344
+ const w = waiters.shift();
345
+ if (w) w({
346
+ value: v,
347
+ done: false
348
+ });
349
+ else queue.push(v);
350
+ },
351
+ close() {
352
+ closed = true;
353
+ drain();
354
+ }
355
+ };
356
+ }
357
+ async function spawnClient(options) {
358
+ const binary = await resolveTuichatBinary();
359
+ const server = createServer();
360
+ await new Promise((resolve, reject) => {
361
+ server.once("error", reject);
362
+ server.listen({
363
+ host: "127.0.0.1",
364
+ port: 0
365
+ }, () => {
366
+ server.off("error", reject);
367
+ resolve();
368
+ });
369
+ });
370
+ const addr = server.address();
371
+ if (!addr || typeof addr === "string") {
372
+ server.close();
373
+ throw new Error("tuichat: failed to bind adapter listener");
374
+ }
375
+ const host = "127.0.0.1";
376
+ const port = addr.port;
377
+ const proc = spawn(binary, ["--connect", `${host}:${port}`], { stdio: "inherit" });
378
+ proc.unref();
379
+ proc.once("exit", (code) => {
380
+ if (code !== 0 && code !== null) process.stderr.write(`[tuichat] subprocess exited with code ${code}\n`);
381
+ });
382
+ const session = new RpcSession(await new Promise((resolve, reject) => {
383
+ let settled = false;
384
+ const cleanup = () => {
385
+ clearTimeout(timer);
386
+ server.off("connection", onConnect);
387
+ server.off("error", onServerError);
388
+ proc.off("error", onProcError);
389
+ proc.off("exit", onProcExit);
390
+ };
391
+ const fail = (err, killProc) => {
392
+ if (settled) return;
393
+ settled = true;
394
+ cleanup();
395
+ server.close();
396
+ if (killProc && !proc.killed) try {
397
+ proc.kill();
398
+ } catch {}
399
+ reject(err);
400
+ };
401
+ const succeed = (sock) => {
402
+ if (settled) return;
403
+ settled = true;
404
+ cleanup();
405
+ server.close();
406
+ resolve(sock);
407
+ };
408
+ const onConnect = (sock) => succeed(sock);
409
+ const onServerError = (err) => fail(err, true);
410
+ const onProcError = (err) => fail(err, false);
411
+ const onProcExit = (code, signal) => fail(/* @__PURE__ */ new Error(`tuichat: subprocess exited before connecting (code=${code ?? "null"}, signal=${signal ?? "null"})`), false);
412
+ const timer = setTimeout(() => {
413
+ fail(/* @__PURE__ */ new Error(`tuichat: subprocess did not connect within ${SPAWN_CONNECT_TIMEOUT_MS}ms`), true);
414
+ }, SPAWN_CONNECT_TIMEOUT_MS);
415
+ server.once("connection", onConnect);
416
+ server.once("error", onServerError);
417
+ proc.once("error", onProcError);
418
+ proc.once("exit", onProcExit);
419
+ }));
420
+ const eventsQ = makeEventQueue();
421
+ session.handleNotifications((method, params) => {
422
+ if (method === "streamEnd") {
423
+ eventsQ.close();
424
+ return;
425
+ }
426
+ if (method === "message") {
427
+ eventsQ.push({
428
+ kind: "message",
429
+ value: params
430
+ });
431
+ return;
432
+ }
433
+ if (method === "reaction") {
434
+ eventsQ.push({
435
+ kind: "reaction",
436
+ value: params
437
+ });
438
+ return;
439
+ }
440
+ });
441
+ let hijack;
442
+ session.onClosed(() => {
443
+ hijack?.restore();
444
+ eventsQ.close();
445
+ });
446
+ try {
447
+ await session.request("initialize", {
448
+ commands: options.commands,
449
+ clientInfo: {
450
+ name: "spectrum-ts",
451
+ version: "terminal-provider"
452
+ }
453
+ }, INITIALIZE_TIMEOUT_MS);
454
+ } catch (err) {
455
+ session.close();
456
+ try {
457
+ proc.kill("SIGTERM");
458
+ } catch {}
459
+ throw err;
460
+ }
461
+ hijack = installConsoleHijack(session);
462
+ return {
463
+ hijack,
464
+ proc,
465
+ session,
466
+ events: eventsQ.iter,
467
+ knownChats: /* @__PURE__ */ new Set(),
468
+ nextChatIndex: 1
469
+ };
470
+ }
471
+ function parseTimestamp(s) {
472
+ const t = Date.parse(s);
473
+ return Number.isNaN(t) ? /* @__PURE__ */ new Date() : new Date(t);
474
+ }
475
+ function buildOutboundRecord(result, content, spaceId) {
476
+ return {
477
+ id: result.id,
478
+ content,
479
+ space: { id: spaceId },
480
+ timestamp: parseTimestamp(result.timestamp)
481
+ };
482
+ }
483
+ function reactionTargetFromProtocol(reaction) {
484
+ return {
485
+ id: reaction.messageId,
486
+ content: asCustom({
487
+ terminal_type: "reaction-target",
488
+ stub: true
489
+ }),
490
+ sender: { id: "__unknown__" },
491
+ space: { id: reaction.spaceId },
492
+ timestamp: parseTimestamp(reaction.timestamp)
493
+ };
494
+ }
495
+ function reactionContentFromProtocol(reaction) {
496
+ return reactionSchema.parse({
497
+ type: "reaction",
498
+ emoji: reaction.reaction,
499
+ target: reactionTargetFromProtocol(reaction)
500
+ });
501
+ }
502
+ async function spectrumToProtocol(content) {
503
+ if (content.type === "text" || content.type === "custom") return content;
504
+ if (content.type === "attachment") {
505
+ const buf = await content.read();
506
+ return {
507
+ type: "attachment",
508
+ name: content.name,
509
+ mimeType: content.mimeType,
510
+ size: content.size,
511
+ bytes: buf.toString("base64")
512
+ };
513
+ }
514
+ if (content.type === "voice") {
515
+ const buf = await content.read();
516
+ return {
517
+ type: "voice",
518
+ name: content.name,
519
+ mimeType: content.mimeType,
520
+ size: content.size,
521
+ bytes: buf.toString("base64")
522
+ };
523
+ }
524
+ if (content.type === "contact") return {
525
+ type: "contact",
526
+ name: content.name ? {
527
+ formatted: content.name.formatted,
528
+ first: content.name.first,
529
+ last: content.name.last
530
+ } : void 0,
531
+ vcard: await toVCard(content)
532
+ };
533
+ if (content.type === "app") return {
534
+ type: "text",
535
+ text: await content.url()
536
+ };
537
+ throw UnsupportedError.content(content.type, "Terminal");
538
+ }
539
+ function protocolToSpectrum(p) {
540
+ if (p.type === "text" || p.type === "custom") return p;
541
+ if (p.type === "attachment" || p.type === "voice") {
542
+ const path = p.path;
543
+ const bytesB64 = p.bytes;
544
+ let cached;
545
+ const readBytes = () => {
546
+ if (cached) return cached;
547
+ if (bytesB64) cached = Promise.resolve(Buffer.from(bytesB64, "base64"));
548
+ else if (path) cached = import("node:fs/promises").then((m) => m.readFile(path));
549
+ else cached = Promise.reject(/* @__PURE__ */ new Error(`${p.type} has neither path nor bytes`));
550
+ return cached;
551
+ };
552
+ const stream = async () => {
553
+ if (path) {
554
+ const [{ createReadStream }, { Readable }] = await Promise.all([import("node:fs"), import("node:stream")]);
555
+ return Readable.toWeb(createReadStream(path));
556
+ }
557
+ const buf = await readBytes();
558
+ return new ReadableStream({ start(ctrl) {
559
+ ctrl.enqueue(new Uint8Array(buf));
560
+ ctrl.close();
561
+ } });
562
+ };
563
+ if (p.type === "attachment") return asAttachment({
564
+ name: p.name,
565
+ mimeType: p.mimeType,
566
+ size: p.size,
567
+ read: readBytes,
568
+ stream
569
+ });
570
+ return asVoice({
571
+ name: p.name,
572
+ mimeType: p.mimeType,
573
+ size: p.size,
574
+ read: readBytes,
575
+ stream
576
+ });
577
+ }
578
+ if (p.type === "contact") {
579
+ if (p.vcard) try {
580
+ return asContact(fromVCard(p.vcard));
581
+ } catch {}
582
+ return asContact({ name: p.name });
583
+ }
584
+ return {
585
+ type: "custom",
586
+ raw: p
587
+ };
588
+ }
589
+ const terminal = definePlatform("Terminal", {
590
+ config: z.object({ commands: z.array(commandSchema).optional() }),
591
+ message: { schema: z.object({ replyTo: z.object({ messageId: z.string() }).optional() }) },
592
+ lifecycle: {
593
+ createClient: async ({ config }) => await spawnClient({ commands: config.commands }),
594
+ destroyClient: async ({ client }) => {
595
+ client.hijack.restore();
596
+ try {
597
+ await client.session.request("shutdown", void 0, SHUTDOWN_TIMEOUT_MS);
598
+ } catch {}
599
+ client.session.close();
600
+ try {
601
+ client.proc.kill("SIGTERM");
602
+ } catch {}
603
+ }
604
+ },
605
+ user: { resolve: async ({ input }) => ({ id: input.userID }) },
606
+ space: {
607
+ create: async ({ client }) => {
608
+ const id = generateChatId(client);
609
+ client.knownChats.add(id);
610
+ await client.session.request("ensureSpace", { id });
611
+ return { id };
612
+ },
613
+ get: async ({ client, input }) => {
614
+ client.knownChats.add(input.id);
615
+ await client.session.request("ensureSpace", { id: input.id });
616
+ return { id: input.id };
617
+ }
618
+ },
619
+ messages({ client }) {
620
+ return stream((emit, end) => {
621
+ const iterator = client.events[Symbol.asyncIterator]();
622
+ const pump = (async () => {
623
+ try {
624
+ let result = await iterator.next();
625
+ while (!result.done) {
626
+ const evt = result.value;
627
+ if (evt.kind === "message") {
628
+ const msg = evt.value;
629
+ client.knownChats.add(msg.spaceId);
630
+ await emit({
631
+ id: msg.id,
632
+ content: protocolToSpectrum(msg.content),
633
+ sender: { id: msg.senderId },
634
+ space: { id: msg.spaceId },
635
+ timestamp: parseTimestamp(msg.timestamp),
636
+ ...msg.replyTo ? { replyTo: msg.replyTo } : {}
637
+ });
638
+ } else {
639
+ const r = evt.value;
640
+ client.knownChats.add(r.spaceId);
641
+ await emit({
642
+ id: `reaction:${r.messageId}:${r.reaction}:${r.timestamp}`,
643
+ content: reactionContentFromProtocol(r),
644
+ sender: { id: r.senderId },
645
+ space: { id: r.spaceId },
646
+ timestamp: parseTimestamp(r.timestamp)
647
+ });
648
+ }
649
+ result = await iterator.next();
650
+ }
651
+ end();
652
+ } catch (error) {
653
+ end(error);
654
+ }
655
+ })();
656
+ return async () => {
657
+ await iterator.return?.();
658
+ await pump.catch(() => void 0);
659
+ };
660
+ });
661
+ },
662
+ send: async ({ client, content, space }) => {
663
+ if (content.type === "reply") {
664
+ const inner = await spectrumToProtocol(content.content);
665
+ return buildOutboundRecord(await client.session.request("replyToMessage", {
666
+ spaceId: space.id,
667
+ messageId: content.target.id,
668
+ content: inner
669
+ }), content.content, space.id);
670
+ }
671
+ if (content.type === "reaction") {
672
+ await client.session.request("reactToMessage", {
673
+ spaceId: space.id,
674
+ messageId: content.target.id,
675
+ reaction: content.emoji
676
+ });
677
+ const timestamp = /* @__PURE__ */ new Date();
678
+ return {
679
+ id: `reaction:${content.target.id}:${content.emoji}:${timestamp.toISOString()}`,
680
+ content,
681
+ space: { id: space.id },
682
+ timestamp
683
+ };
684
+ }
685
+ if (content.type === "typing") {
686
+ const method = content.state === "start" ? "startTyping" : "stopTyping";
687
+ await client.session.request(method, { spaceId: space.id });
688
+ return;
689
+ }
690
+ const proto = await spectrumToProtocol(content);
691
+ return buildOutboundRecord(await client.session.request("send", {
692
+ spaceId: space.id,
693
+ content: proto
694
+ }), content, space.id);
695
+ }
696
+ });
697
+ //#endregion
698
+ export { terminal };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@spectrum-ts/terminal",
3
+ "version": "5.0.0",
4
+ "description": "Terminal (tuichat) provider for spectrum-ts — chat with your agent from the command line.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/photon-hq/spectrum-ts.git",
8
+ "directory": "packages/terminal"
9
+ },
10
+ "homepage": "https://photon.codes/spectrum",
11
+ "bugs": {
12
+ "url": "https://github.com/photon-hq/spectrum-ts/issues"
13
+ },
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "spectrum": {
29
+ "key": "terminal",
30
+ "import": "terminal",
31
+ "label": "Terminal"
32
+ },
33
+ "dependencies": {
34
+ "zod": "^4.2.1"
35
+ },
36
+ "peerDependencies": {
37
+ "@spectrum-ts/core": "^5.0.0",
38
+ "typescript": "^5 || ^6.0.0"
39
+ },
40
+ "license": "MIT"
41
+ }