@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 +21 -0
- package/README.md +20 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.js +698 -0
- package/package.json +41 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|