@lengmoxxl/sdk 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -1,54 +1,152 @@
1
- # @lengmoxxl/sdk
2
- The client SDK for [WangCai](https://github.com/lengmoXXL/WangCai), the terminal app. It reaches a machine, the one
3
- the app runs on or one over SSH, and drives its terminals, files and subprocesses through a single connection.
1
+ # @lengmoxxl/sdk:接口参考
4
2
 
5
- A WangCai plugin compiles against this package for the types it uses; the machines belong to the app, which keeps
6
- one connection per machine and hands it to every plugin that asks for one.
3
+ 本参考对应 SDK `0.1.5`:连接本机或 SSH 机器上的旺财后台进程,提供终端、文件与子进程
4
+ 操作。插件入口与生命周期见 [插件开发手册](../docs/plugins.md),进程关系见
5
+ [架构说明](../docs/architecture.md)。
7
6
 
8
- ## Install
7
+ ## 插件上下文
9
8
 
10
- ```
11
- npm install @lengmoxxl/sdk
12
- ```
9
+ | 成员 | 说明 |
10
+ | --- | --- |
11
+ | `global` | 公共 `Bus`,跨插件与前后端通信 |
12
+ | `ui.handle<T>(method, handler)` | 后端注册方法,返回取消注册的 `Dispose` |
13
+ | `ui.publish(topic, data)` | 后端向本插件 UI 发事件,对应前端 `ui.subscribe` |
14
+ | `ui.subscribe<T>(topic, callback)` | 前端订阅本插件后端事件,返回取消订阅的函数 |
15
+ | `ui.request<T>(method, params?)` | 前端调用本插件后端方法,返回 `Promise<T>` |
16
+ | `connect(machine, signal?)` | 后端借用机器连接:`{ host?: string }`,可带 `AbortSignal` |
17
+ | `host.dataDirectory` / `host.config` | 插件私有数据目录 / 本插件设置 |
18
+ | `host.tabs(options)` / `host.open(url)` | 前端开侧栏标签页 / 用系统浏览器打开 http、https |
19
+ | `host.preview` | `{ url, message }`,HTML 预览 frame 的地址与握手消息 |
13
20
 
14
- `ws` is the only runtime dependency. The bundled `dist/index.cjs` reads it at require time.
21
+ 请求与事件传可结构化克隆的数据,不传函数、DOM、Pty 或连接实例。
15
22
 
16
- ## Use
23
+ `ui.handle` 注册的方法名不能含 `:`;`ui.publish` 与 `ui.subscribe` 的主题由宿主加插件 id 前缀,
24
+ `global` 用原始主题,自定义主题要自己加命名空间(如 `directory-demo:opened`)。
17
25
 
18
- ```ts
19
- import { openMachine } from '@lengmoxxl/sdk';
26
+ `connect({})` 连接本机,`connect({ host })` 连接 SSH。
20
27
 
21
- const connection = await openMachine({ type: 'local', binary: '/path/to/wangcai' });
28
+ 宿主为相同机器复用连接并计数,每次取得连接对应一次 `disconnect()`。
22
29
 
23
- const sessions = await connection.pty.list();
24
- const session = await connection.pty.create({ rows: 24, cols: 80 }, '/repo');
25
- const terminal = await connection.pty.attach(session.id);
26
- terminal.onData(({ data }) => process.stdout.write(data));
27
- await terminal.write('git status\n');
30
+ `Dispose = () => void | Promise<void>`,卸载时清理资源,不要关闭用户仍要保留的 PTY。
28
31
 
29
- const { stdout } = await connection.subprocess.exec('git', ['status'], { cwd: '/repo' });
30
- connection.disconnect();
31
- ```
32
+ `Bus`:`publish(topic, data): Promise<void>`、`subscribe<T>(topic, callback): () => void`。
32
33
 
33
- `openMachine` returns a `MachineConnection`; every call opens its own. Sharing one belongs to the caller: a
34
- connection is reference counted, so callers that hand the same one around keep it alive until the last
35
- `disconnect()`. `openMachine({ type: 'ssh', host, agent })` installs the carrier agent on the host first when
36
- `agent` is given.
34
+ `host.dataDirectory` 默认 `~/.local/share/wangcai/data/<plugin-id>`。
37
35
 
38
- - `connection.pty` - `list`, `cwd`, `create`, `attach`, `close`
39
- - `connection.fs` - `stat`, `readDirectory`, `readFile`
40
- - `connection.subprocess` - `exec`, which returns `{ stdout, stderr, code }` as `Uint8Array`
41
- - `connection.onState` - connection status and the sessions it can see
36
+ `host.config` 是浅合并,避免把自定义字段命名为 `theme`,配色键见
37
+ [README 主题参数](../README.md#主题参数)。
42
38
 
43
- The host-side context a plugin is activated with (`MainContext`, `UiContext`, `Bus`, and the workspace protocol
44
- types) lives in the `@lengmoxxl/sdk/channel` entry. That entry is types only.
39
+ ## 标签页
45
40
 
46
- ## Build
41
+ `host.tabs({ id, title, tooltip?, workspaceId?, onClose?, mount })`:`id`、`title` 必填,
42
+ `tooltip?` 是标签上的悬停提示(缺省用 `title`),`workspaceId` 缺省用当前工作区;
43
+ `mount(container)` 返回的 `TabContent.dispose` 必填,`onSelect?` 在选中、工作区刷新或侧栏重新显示时
44
+ 都可能调用,`onClose?` 在移除标签页时先于 `dispose` 调用。
47
45
 
46
+ 相同插件 id、工作区 id、标签 id 再次调用 `host.tabs` 会复用并选中;尚无工作区时打开的标签页
47
+ 会在第一个工作区激活时绑定到它。
48
+
49
+ `TabRecord = { plugin, id, workspaceId? }` 只保存身份,插件的 `open(context, record)` 据此恢复
50
+ 内容,资源必须仍在。
51
+
52
+ ## 预览
53
+
54
+ 用 `host.preview.url` 建 iframe,设 `sandbox="allow-scripts"`,不要加
55
+ `allow-same-origin`;收到来源为该 frame、`event.data === host.preview.message` 的消息后
56
+ `postMessage(html, '*')`。预览会执行其中脚本。
57
+
58
+ ## 插件配置
59
+
60
+ 插件 `config` 是嵌套对象,叶子声明 `type` 与可选 `default`;宿主只保留 schema 声明的键,
61
+ 缺失或类型不符时取 default,`array` 内部条目由插件自己验证。
62
+
63
+ ## 工作区协议
64
+
65
+ 放入 init.ts `workspaces` 的插件用 `ui.handle` 提供:`workspaces()`(`WorkspaceRow[]`,
66
+ 按显示顺序)、`workspace-menu()`(`WorkspaceMenuItem[]`)、`workspace-create({ key })`
67
+ (返回 `{ id }`)、`workspace-select({ id })`、`workspace-close({ id })`、
68
+ `workspace-move({ id, before? })`(`before` 是目标工作区 id,不是索引;省略为末尾,且只
69
+ 允许同一 provider 内拖动)。
70
+
71
+ provider 要保证工作区 id 在所有 provider 的行中唯一识别;`error` 由 provider 给出并在重试成功后清除,
72
+ 宿主不禁用带 `error` 的菜单项。
73
+
74
+ 约定的公共主题:`workspaces`(行变化,App 重新请求列表)、`workspace:active`
75
+ (`WorkspaceActive | null`,provider 发布)、`workspace:query`(视图请求 provider 再发布
76
+ 当前工作区)、`onclick`(`FileClick`)。
77
+
78
+ 字段类型见 SDK 导出的 `WorkspaceRow`、`WorkspaceMenuItem`、`Machine`、`WorkspaceActive`
79
+ 与 `FileClick`,定义在 `sdk/types.ts`。
80
+
81
+ ## 机器、终端与子进程
82
+
83
+ `MachineConnection`:`ready`(首次连接就绪)、`state`、`port`(本地 loopback 端口,SSH 时
84
+ 为转发端口)、`onState(callback)`、`retain()`(连接层使用)、`disconnect()`,以及 `pty`、`fs`、
85
+ `subprocess`。
86
+
87
+ `MachineState` 含 `status`、`error?`、`sessions`、`generation`。
88
+
89
+ 断线会自动重连并重新 attach 已挂载的终端与进程,失败的请求与输入不自动重放,后台进程重启时旧 Pty 与进程失效。
90
+
91
+ `connection.pty`:`list()`、`cwd(id)`、`create(size?, cwd?)`(默认 24 行 80 列;cwd 用绝对目录,
92
+ 省略时在后台进程的 HOME 下启动)、`attach(id)`(复用句柄)、`close(id)`(结束 shell 与后台子孙进程)。
93
+
94
+ `Size` 用整数:rows 2–300,cols 2–500。
95
+
96
+ `Session` 有 `id, title`、`pid, rows, cols`、`exit_code: number | null`。
97
+
98
+ `Pty`:`id`、`onSnapshot(callback)`、`onData(callback)`、`write(data)`、`resize(size)`、
99
+ `detach()`(停止挂载,不关闭 shell)。
100
+
101
+ `Output = { data: Uint8Array; seq: number }`,`Snapshot` 额外有 `rows, cols`。
102
+
103
+ 字节含终端控制序列,交给终端模拟器,不要把快照当键盘输入重发。
104
+
105
+ `receive` 与 `release(error?)` 由连接层使用。
106
+
107
+ `connection.fs`:`stat(path)` 返回 `{ isDirectory: boolean } | null`、`readDirectory(path)` 返回
108
+ `DirectoryEntry[]`(`{ name, isDirectory }`)、`readFile(path)` 返回原始字节。
109
+
110
+ 路径为绝对路径,`readFile` 上限 16 MiB;SDK 不提供写入、删除或上传接口。
111
+
112
+ `connection.subprocess.exec(program, args, options)`:`args` 是独立 argv(不经 shell 展开),
113
+ `options.cwd` 是绝对工作目录,`options.env` 覆盖继承的环境变量;返回
114
+ `{ stdout: Uint8Array; stderr: Uint8Array; code: number }`。非零退出码仍返回结果,无法启动、
115
+ 超时、输出超限或被信号终止时 reject;stdin 关闭,长时间或交互用 `subprocess.spawn` 或 PTY。
116
+
117
+ 执行上限 15 秒,SDK 等待响应上限 20 秒,stdout 与 stderr 各最多 8 MiB,断线取消未完成的 exec。
118
+
119
+ `connection.subprocess.spawn(program, args, options)`(`options` 同 `exec`)启动一个
120
+ 长期存活的进程,stdin、stdout、stderr 都是管道,返回 `ProcessInfo = { id, pid, exit_code }`;
121
+ `attach(id)` 复用句柄(未 attach 前不接收输出),`close(id)` 结束进程及其进程组;进程归后台进程所有,不 `close` 就一直留着。
122
+
123
+ `Process`:`id`、`onData(callback)`、`onExit(callback)`、`write(data)`。
124
+
125
+ `ProcessOutput = { data: Uint8Array; seq: number; stream: 'stdout' | 'stderr' }`,两路输出各自按序到达,
126
+ `seq` 单调递增,跨两路的先后以 `seq` 为准。
127
+
128
+ `onExit` 给出退出码(被信号结束时按 shell 的 128+signal),节点来不及投递时给出 `undefined`,此时
129
+ 输出流已不可信,进程可能仍在运行,请重新 `spawn`。
130
+
131
+ `write` 按 32 KiB 分片、按序应用,进程退出后 reject;先 `attach` 再写。
132
+
133
+ `receive` 与 `release(error?)` 由连接层使用。
134
+
135
+ ## 独立 Node 客户端
136
+
137
+ App 插件用 `context.connect`;插件之外才调用 `openMachine`:
138
+
139
+ ```ts
140
+ import { openMachine } from '@lengmoxxl/sdk';
141
+
142
+ const connection = await openMachine({ type: 'local', binary: '/path/to/wangcai' });
143
+ await connection.fs.readDirectory('/');
144
+ connection.disconnect();
48
145
  ```
49
- npm run build
50
- ```
51
146
 
52
- From the repository root (the TypeScript and esbuild devDependencies live in the workspace root): esbuild bundles
53
- `index.ts` into `dist/index.cjs` with `ws` left external, then `tsc` emits the declarations. `npm publish` and
54
- `npm pack` run it for you.
147
+ `openMachine` 返回 `Promise<MachineConnection>`。`type` 为 `'local'` 或 `'ssh'`;local 可给
148
+ `binary`,ssh 必须给 `host`(系统 ssh 目标或别名)与可选 `agent`(`InstallAgent`),两者
149
+ 都可给 `signal`。不传 `agent` 时 SDK 不下载二进制;连接用 BatchMode SSH。
150
+
151
+ `agentCommand(command)` 返回拼接后的 shell 命令:在 PATH 前加入远端 `$HOME/.local/bin`,
152
+ 并携带当前进程的 WANGCAI_HOME(会转义;command 本身不转义,只传可信命令)。
package/dist/agent.d.ts CHANGED
@@ -1,5 +1 @@
1
- import type { AgentInfo } from './types';
2
1
  export declare function agentCommand(command: string): string;
3
- export declare function ensureAgent(host: string, { version, prefixes, signal }: AgentInfo & {
4
- signal?: AbortSignal;
5
- }): Promise<void>;
@@ -1,9 +1,12 @@
1
- import type { ConnectionOptions, MachineState, Session, Size, DirectoryEntry, ExecOptions, ExecResult } from './types';
1
+ import type { ConnectionOptions, MachineState, Session, Size, DirectoryEntry, ExecOptions, ExecResult, ProcessInfo } from './types';
2
+ import { Process } from './process';
2
3
  import { Pty } from './pty';
3
4
  export declare class MachineConnection {
4
5
  private options;
5
6
  private listeners;
6
7
  private terminals;
8
+ private processes;
9
+ port?: number;
7
10
  private instance?;
8
11
  readonly ready: Promise<void>;
9
12
  private resolveReady;
@@ -41,6 +44,9 @@ export declare class MachineConnection {
41
44
  };
42
45
  subprocess: {
43
46
  exec: (program: string, args: string[], options: ExecOptions) => Promise<ExecResult>;
47
+ spawn: (program: string, args: string[], options: ExecOptions) => Promise<ProcessInfo>;
48
+ attach: (id: string) => Promise<Process>;
49
+ close: (id: string) => Promise<void>;
44
50
  };
45
51
  private cleanup;
46
52
  private fail;
package/dist/index.cjs CHANGED
@@ -30,16 +30,79 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ agentCommand: () => agentCommand,
33
34
  openMachine: () => openMachine
34
35
  });
35
36
  module.exports = __toCommonJS(index_exports);
36
37
 
37
38
  // connection.ts
38
- var import_node_child_process2 = require("node:child_process");
39
+ var import_node_child_process = require("node:child_process");
39
40
  var import_node_net = require("node:net");
40
- var import_node_util2 = require("node:util");
41
+ var import_node_util = require("node:util");
41
42
  var import_ws = __toESM(require("ws"));
42
43
 
44
+ // process.ts
45
+ var CHUNK = 32 * 1024;
46
+ var Process = class {
47
+ constructor(id, request) {
48
+ this.id = id;
49
+ this.request = request;
50
+ }
51
+ sequence = -1;
52
+ error;
53
+ ended = false;
54
+ code;
55
+ buffered = [];
56
+ outputs = /* @__PURE__ */ new Set();
57
+ exits = /* @__PURE__ */ new Set();
58
+ receive(event) {
59
+ if (this.error) return;
60
+ if (event.event === "exit" || event.event === "lost") {
61
+ this.ended = true;
62
+ this.code = event.event === "exit" ? event.code : void 0;
63
+ for (const callback of this.exits) callback(this.code);
64
+ this.exits.clear();
65
+ return;
66
+ }
67
+ if (event.seq <= this.sequence) return;
68
+ this.sequence = event.seq;
69
+ const output = { data: event.data, seq: event.seq, stream: event.event };
70
+ if (!this.outputs.size) this.buffered.push(output);
71
+ for (const callback of this.outputs) callback(output);
72
+ }
73
+ onData(callback) {
74
+ this.outputs.add(callback);
75
+ for (const event of this.buffered) callback(event);
76
+ this.buffered = [];
77
+ return () => {
78
+ this.outputs.delete(callback);
79
+ };
80
+ }
81
+ /** Its exit code, or nothing when the node lost the stream and the process may still be running. */
82
+ onExit(callback) {
83
+ this.exits.add(callback);
84
+ if (this.ended) callback(this.code);
85
+ return () => {
86
+ this.exits.delete(callback);
87
+ };
88
+ }
89
+ async write(data) {
90
+ if (this.error) throw this.error;
91
+ const requests = [];
92
+ for (let offset = 0; offset < data.length; offset += CHUNK) {
93
+ const chunk = Buffer.from(data.subarray(offset, offset + CHUNK)).toString("base64");
94
+ requests.push(this.request("process_write", { session_id: this.id, data: chunk }));
95
+ }
96
+ await Promise.all(requests);
97
+ }
98
+ release(error = new Error("Process detached")) {
99
+ this.error = error;
100
+ this.outputs.clear();
101
+ this.exits.clear();
102
+ this.buffered = [];
103
+ }
104
+ };
105
+
43
106
  // pty.ts
44
107
  var Pty = class {
45
108
  constructor(id, request, remove) {
@@ -111,61 +174,14 @@ var Pty = class {
111
174
  };
112
175
 
113
176
  // agent.ts
114
- var import_node_child_process = require("node:child_process");
115
- var import_node_util = require("node:util");
116
- var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
117
- var DOWNLOAD_TIMEOUT = 3e4;
118
177
  function agentCommand(command) {
119
178
  const home = process.env.WANGCAI_HOME;
120
179
  const exportHome = home ? `export WANGCAI_HOME='${home.replace(/'/g, String.raw`'\''`)}'; ` : "";
121
180
  return `${exportHome}export PATH="$HOME/.local/bin:$PATH"; ${command}`;
122
181
  }
123
- async function ensureAgent(host, { version, prefixes, signal }) {
124
- const targets = {
125
- "Darwin x86_64": "x86_64-apple-darwin",
126
- "Darwin arm64": "aarch64-apple-darwin",
127
- "Linux x86_64": "x86_64-unknown-linux-musl"
128
- };
129
- const remote = (command) => ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host, command];
130
- const probe = await exec("ssh", remote(agentCommand("uname -s -m; wangcai --version 2>/dev/null; true")), { timeout: 3e4, signal }).catch(() => void 0);
131
- const [platform, installed] = (probe?.stdout ?? "").split("\n").map((line) => line.trim());
132
- if (!platform) return;
133
- const target = targets[platform];
134
- if (!target) throw new Error(`${platform} \u4E0A\u6682\u4E0D\u652F\u6301\u81EA\u52A8\u5B89\u88C5 agent`);
135
- if (installed === `wangcai ${version}`) return;
136
- const failures = [];
137
- for (const prefix of prefixes) {
138
- signal?.throwIfAborted();
139
- const url = `${prefix}/v${version}/wangcai-${target}`;
140
- const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT);
141
- let binary;
142
- try {
143
- const response = await fetch(url, { signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
144
- if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
145
- binary = Buffer.from(await response.arrayBuffer());
146
- } catch (error) {
147
- failures.push(`${url}
148
- ${error instanceof Error ? error.message : String(error)}`);
149
- continue;
150
- }
151
- await new Promise((resolve, reject) => {
152
- const child = (0, import_node_child_process.spawn)("ssh", remote("mkdir -p ~/.local/bin && cat > ~/.local/bin/.wangcai.tmp && chmod 755 ~/.local/bin/.wangcai.tmp && mv ~/.local/bin/.wangcai.tmp ~/.local/bin/wangcai"), { stdio: ["pipe", "ignore", "pipe"] });
153
- let stderr = "";
154
- child.stderr.on("data", (chunk) => {
155
- stderr += chunk;
156
- });
157
- child.on("error", reject);
158
- child.on("close", (code) => code === 0 ? resolve() : reject(new Error(stderr.trim() || `\u5199\u5165 agent \u5931\u8D25\uFF08ssh \u9000\u51FA\u7801 ${code}\uFF09`)));
159
- child.stdin.end(binary);
160
- });
161
- return;
162
- }
163
- throw new Error(`\u4E0B\u8F7D agent \u5931\u8D25\uFF1A
164
- ${failures.join("\n\n")}`);
165
- }
166
182
 
167
183
  // connection.ts
168
- var exec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
184
+ var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
169
185
  var MachineConnection = class {
170
186
  constructor(options) {
171
187
  this.options = options;
@@ -178,6 +194,9 @@ var MachineConnection = class {
178
194
  }
179
195
  listeners = /* @__PURE__ */ new Set();
180
196
  terminals = /* @__PURE__ */ new Map();
197
+ processes = /* @__PURE__ */ new Map();
198
+ // Where the daemon listens: the window reaches it with this.
199
+ port;
181
200
  instance;
182
201
  ready;
183
202
  resolveReady;
@@ -218,6 +237,8 @@ var MachineConnection = class {
218
237
  this.rejectReady(new Error("Connection cancelled"));
219
238
  for (const terminal of this.terminals.values()) terminal.release();
220
239
  this.terminals.clear();
240
+ for (const process2 of this.processes.values()) process2.release();
241
+ this.processes.clear();
221
242
  this.state.status = "disconnected";
222
243
  this.state.error = void 0;
223
244
  this.publish();
@@ -261,6 +282,26 @@ var MachineConnection = class {
261
282
  exec: async (program, args, options) => {
262
283
  const result = await this.request("exec", { program, args, ...options }, 2e4);
263
284
  return { stdout: Buffer.from(result.stdout, "base64"), stderr: Buffer.from(result.stderr, "base64"), code: result.code };
285
+ },
286
+ spawn: (program, args, options) => this.request("spawn", { program, args, ...options }),
287
+ attach: async (id) => {
288
+ const existing = this.processes.get(id);
289
+ if (existing) return existing;
290
+ const process2 = new Process(id, (op, params) => this.request(op, params));
291
+ this.processes.set(id, process2);
292
+ try {
293
+ await this.request("process_attach", { session_id: id });
294
+ } catch (error) {
295
+ this.processes.delete(id);
296
+ process2.release();
297
+ throw error;
298
+ }
299
+ return process2;
300
+ },
301
+ close: async (id) => {
302
+ await this.request("process_close", { session_id: id });
303
+ this.processes.get(id)?.release();
304
+ this.processes.delete(id);
264
305
  }
265
306
  };
266
307
  cleanup() {
@@ -300,12 +341,12 @@ var MachineConnection = class {
300
341
  let info;
301
342
  let port;
302
343
  if (this.options.type === "local") {
303
- const { stdout } = await exec2(this.options.binary ?? "wangcai", ["server", "start", "--json"], { timeout: 15e3 });
344
+ const { stdout } = await exec(this.options.binary ?? "wangcai", ["server", "start", "--json"], { timeout: 15e3 });
304
345
  info = JSON.parse(stdout);
305
346
  port = info.port;
306
347
  } else {
307
- if (this.options.agent) await ensureAgent(this.options.host, { ...this.options.agent, signal: this.options.signal });
308
- const { stdout } = await exec2("ssh", [
348
+ if (this.options.agent) await this.options.agent(this.options.host, this.options.signal);
349
+ const { stdout } = await exec("ssh", [
309
350
  "-T",
310
351
  "-o",
311
352
  "BatchMode=yes",
@@ -319,6 +360,7 @@ var MachineConnection = class {
319
360
  if (!current()) return;
320
361
  port = await this.forward(info.port, current);
321
362
  }
363
+ this.port = port;
322
364
  if (!current()) return;
323
365
  const socket = new import_ws.default(`ws://127.0.0.1:${port}`, { handshakeTimeout: 5e3, maxPayload: 64 * 1024 * 1024 });
324
366
  this.ws = socket;
@@ -328,9 +370,12 @@ var MachineConnection = class {
328
370
  if (binary) {
329
371
  const bytes = data;
330
372
  const length = bytes.readUInt32BE(0);
331
- if (length > bytes.length - 4) throw new Error("Invalid terminal packet");
373
+ if (length > bytes.length - 4) throw new Error("Invalid packet");
332
374
  const header = JSON.parse(bytes.subarray(4, 4 + length).toString());
333
- this.terminals.get(header.session_id)?.receive({ ...header, data: bytes.subarray(4 + length) });
375
+ const event = { ...header, data: bytes.subarray(4 + length) };
376
+ const terminal = this.terminals.get(header.session_id);
377
+ if (terminal) terminal.receive(event);
378
+ else this.processes.get(header.session_id)?.receive(event);
334
379
  } else {
335
380
  const message = JSON.parse(data.toString());
336
381
  if (message.id) {
@@ -369,6 +414,8 @@ var MachineConnection = class {
369
414
  if (this.instance && this.instance !== actual.instance_id) {
370
415
  for (const terminal of this.terminals.values()) terminal.release(new Error("Node restarted; terminal no longer exists"));
371
416
  this.terminals.clear();
417
+ for (const process2 of this.processes.values()) process2.release(new Error("Node restarted; process no longer exists"));
418
+ this.processes.clear();
372
419
  }
373
420
  this.instance = actual.instance_id;
374
421
  for (const [id, terminal] of this.terminals) {
@@ -380,6 +427,15 @@ var MachineConnection = class {
380
427
  this.terminals.delete(id);
381
428
  }
382
429
  }
430
+ for (const [id, process2] of this.processes) {
431
+ try {
432
+ await this.request("process_attach", { session_id: id });
433
+ } catch (error) {
434
+ if (!current()) return;
435
+ process2.release(error);
436
+ this.processes.delete(id);
437
+ }
438
+ }
383
439
  if (!current()) return;
384
440
  this.state.status = "connected";
385
441
  this.state.error = void 0;
@@ -415,7 +471,7 @@ var MachineConnection = class {
415
471
  const port = reservation.address().port;
416
472
  await new Promise((resolve) => reservation.close(() => resolve()));
417
473
  if (!current()) throw new Error("Connection cancelled");
418
- const tunnel = (0, import_node_child_process2.spawn)("ssh", [
474
+ const tunnel = (0, import_node_child_process.spawn)("ssh", [
419
475
  "-N",
420
476
  "-T",
421
477
  "-o",
@@ -521,5 +577,6 @@ async function openMachine(options) {
521
577
  }
522
578
  // Annotate the CommonJS export names for ESM import in node:
523
579
  0 && (module.exports = {
580
+ agentCommand,
524
581
  openMachine
525
582
  });
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { MachineConnection } from './connection';
2
2
  import type { ConnectionOptions } from './types';
3
- export type { AgentInfo, ConnectionOptions, FileClick, Machine, MachineState, Session, Size, Output, Snapshot, TerminalEvent, DirectoryEntry, ExecOptions, ExecResult, Profile, Theme, WorkspaceActive, WorkspaceRow, WorkspaceMenuItem } from './types';
3
+ export { agentCommand } from './agent';
4
+ export type { ConnectionOptions, FileClick, Machine, MachineState, Session, Size, Output, Snapshot, TerminalEvent, DirectoryEntry, ExecOptions, ExecResult, ProcessEvent, ProcessInfo, ProcessOutput, InstallAgent, Profile, Theme, WorkspaceActive, WorkspaceRow, WorkspaceMenuItem } from './types';
4
5
  export type { MachineConnection } from './connection';
6
+ export type { Process } from './process';
5
7
  export type { Pty } from './pty';
6
8
  /** Opens one connection to a machine and waits for it to be ready. */
7
9
  export declare function openMachine(options: ConnectionOptions): Promise<MachineConnection>;
@@ -0,0 +1,19 @@
1
+ import type { ProcessEvent, ProcessOutput } from './types';
2
+ export declare class Process {
3
+ readonly id: string;
4
+ private request;
5
+ private sequence;
6
+ private error?;
7
+ private ended;
8
+ private code?;
9
+ private buffered;
10
+ private outputs;
11
+ private exits;
12
+ constructor(id: string, request: (op: string, params: Record<string, unknown>) => Promise<unknown>);
13
+ receive(event: ProcessEvent): void;
14
+ onData(callback: (event: ProcessOutput) => void): () => void;
15
+ /** Its exit code, or nothing when the node lost the stream and the process may still be running. */
16
+ onExit(callback: (code?: number) => void): () => void;
17
+ write(data: Uint8Array): Promise<void>;
18
+ release(error?: Error): void;
19
+ }
package/dist/types.d.ts CHANGED
@@ -1,14 +1,12 @@
1
- export interface AgentInfo {
2
- version: string;
3
- prefixes: string[];
4
- }
1
+ /** What puts the agent on an ssh machine, which the SDK calls before it connects to it. */
2
+ export type InstallAgent = (host: string, signal?: AbortSignal) => Promise<void>;
5
3
  /**
6
4
  * The workspace protocol. A plugin that init.ts lists under `workspaces` is a workspace provider, and
7
5
  * the app drives it through these `ui` methods:
8
6
  *
9
7
  * workspaces() -> WorkspaceRow[] one flat row per workspace, in draw order
10
8
  * workspace-menu() -> WorkspaceMenuItem[] what the menu offers to open; an entry that carries
11
- * an error stays listed but is not clickable
9
+ * an error stays listed with its reason, and a click retries it
12
10
  * workspace-create({ key }) -> { id: string } the id of the workspace it made
13
11
  * workspace-select({ id })
14
12
  * workspace-close({ id })
@@ -58,7 +56,7 @@ export type ConnectionOptions = {
58
56
  } | {
59
57
  type: 'ssh';
60
58
  host: string;
61
- agent?: AgentInfo;
59
+ agent?: InstallAgent;
62
60
  signal?: AbortSignal;
63
61
  };
64
62
  export interface Size {
@@ -90,6 +88,26 @@ export type TerminalEvent = ({
90
88
  } & Output) & {
91
89
  session_id: string;
92
90
  };
91
+ export interface ProcessInfo {
92
+ id: string;
93
+ pid: number;
94
+ exit_code: number | null;
95
+ }
96
+ /** `stream` names the pipe the bytes came from. */
97
+ export interface ProcessOutput extends Output {
98
+ stream: 'stdout' | 'stderr';
99
+ }
100
+ export type ProcessEvent = ({
101
+ event: 'stdout' | 'stderr';
102
+ } & Output | {
103
+ event: 'exit';
104
+ seq: number;
105
+ code: number;
106
+ } | {
107
+ event: 'lost';
108
+ }) & {
109
+ session_id: string;
110
+ };
93
111
  export interface DirectoryEntry {
94
112
  name: string;
95
113
  isDirectory: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengmoxxl/sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Client SDK for WangCai: connect to a local or remote machine and drive its pty, files and git",
5
5
  "license": "MIT",
6
6
  "repository": {