@fengye404/termpilot 0.1.5 → 0.1.7

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
@@ -2,6 +2,10 @@
2
2
 
3
3
  TermPilot 是一个终端优先的远程控制工具。电脑上跑 `tmux` 会话,手机直接打开 relay 域名查看和控制同一批会话。
4
4
 
5
+ 如果你已经准备长期部署,直接看完整运维文档:
6
+
7
+ - [部署与运维指南](/Users/fengye/workspace/TermPilot/docs/operations-guide.md)
8
+
5
9
  ## 产品形态
6
10
 
7
11
  - 一个 npm 包:`@fengye404/termpilot`
@@ -10,7 +14,112 @@ TermPilot 是一个终端优先的远程控制工具。电脑上跑 `tmux` 会
10
14
  - 手机端不安装,直接打开 relay 域名
11
15
  - relay 同时负责消息中继和网页托管
12
16
 
13
- ## 快速开始
17
+ ## 5 分钟快速上手
18
+
19
+ ### 1. 启动 relay
20
+
21
+ 在云服务器或一台能被手机访问到的机器上执行:
22
+
23
+ ```bash
24
+ npm install -g @fengye404/termpilot
25
+ termpilot relay
26
+ ```
27
+
28
+ 默认情况下,`termpilot relay` 会直接在后台启动 relay,不占当前窗口。
29
+
30
+ 常用 relay 管理命令:
31
+
32
+ ```bash
33
+ termpilot relay
34
+ termpilot relay stop
35
+ termpilot relay run
36
+ ```
37
+
38
+ - `termpilot relay` 或 `termpilot relay start`:后台启动
39
+ - `termpilot relay stop`:停止后台 relay
40
+ - `termpilot relay run`:前台运行,适合看日志和排查问题
41
+
42
+ 如果你只是先本地体验,也可以直接在自己电脑上跑 relay,然后让手机走局域网访问。
43
+
44
+ ### 2. 启动电脑 agent
45
+
46
+ 在你的电脑上执行:
47
+
48
+ ```bash
49
+ npm install -g @fengye404/termpilot
50
+ termpilot agent
51
+ ```
52
+
53
+ 如果这是第一次运行,`termpilot agent` 会直接在终端里引导你:
54
+
55
+ 1. 输入 relay 域名或 IP
56
+ 2. 输入端口,直接回车默认 `8787`
57
+ 3. 自动保存本机配置
58
+ 4. 后台启动 agent
59
+ 5. 输出一次性配对码
60
+
61
+ 以后日常只需要继续执行:
62
+
63
+ ```bash
64
+ termpilot agent
65
+ ```
66
+
67
+ 这条命令会根据当前状态自动处理:
68
+
69
+ - 没有后台 agent:按本机已保存配置启动
70
+ - 已经有后台 agent:直接显示当前状态
71
+ - 想重新给手机配对:执行 `termpilot agent --pair`
72
+
73
+ 常用管理命令:
74
+
75
+ ```bash
76
+ termpilot agent status
77
+ termpilot agent stop
78
+ termpilot agent --pair
79
+ ```
80
+
81
+ ### 3. 手机完成配对
82
+
83
+ 手机浏览器直接打开 relay 域名:
84
+
85
+ - `http://your-domain.com:8787`
86
+ - 或反代后的 `https://your-domain.com`
87
+
88
+ 然后:
89
+
90
+ 1. 输入电脑端刚打印出来的配对码
91
+ 2. 点“配对”
92
+ 3. 成功后直接进入会话列表
93
+
94
+ ### 4. 直接跑一个可同步的任务
95
+
96
+ 日常最短路径是:
97
+
98
+ ```bash
99
+ termpilot claude code
100
+ ```
101
+
102
+ 或者:
103
+
104
+ ```bash
105
+ termpilot open code
106
+ ```
107
+
108
+ 这会直接:
109
+
110
+ - 创建一个受 TermPilot 管理的 `tmux` 会话
111
+ - 把命令写进这个会话
112
+ - 当前终端自动 attach 进去
113
+ - 手机端同步看到同一个会话
114
+
115
+ ### 5. 你现在应该能做到什么
116
+
117
+ 此时你可以:
118
+
119
+ - 在电脑上看 `claude code` / `open code` 的流式输出
120
+ - 在手机上看同一份输出
121
+ - 在手机上补一条命令、发快捷键、关闭会话
122
+ - 随时在电脑和手机之间切换
14
123
 
15
124
  ### 服务器
16
125
 
@@ -33,7 +142,9 @@ termpilot relay
33
142
  常用参数:
34
143
 
35
144
  ```bash
36
- termpilot relay --host 0.0.0.0 --port 8787
145
+ termpilot relay
146
+ termpilot relay run
147
+ termpilot relay stop
37
148
  DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/termpilot termpilot relay
38
149
  ```
39
150
 
@@ -41,19 +152,13 @@ DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/termpilot termpilot relay
41
152
 
42
153
  ```bash
43
154
  npm install -g @fengye404/termpilot
44
- termpilot agent --relay ws://your-domain.com/ws
155
+ termpilot agent
45
156
  ```
46
157
 
47
- 这条命令现在会:
48
-
49
- - 在后台启动 agent
50
- - 判断这台电脑是否已经有本地 agent 在运行
51
- - 直接输出一次性配对码
52
-
53
158
  如果你只是想看调试日志,可以显式前台运行:
54
159
 
55
160
  ```bash
56
- termpilot agent --relay ws://your-domain.com/ws --foreground
161
+ termpilot agent --foreground
57
162
  ```
58
163
 
59
164
  查看后台状态:
@@ -71,7 +176,7 @@ termpilot agent stop
71
176
  本地测试:
72
177
 
73
178
  ```bash
74
- termpilot agent --relay ws://127.0.0.1:8787/ws
179
+ termpilot agent
75
180
  ```
76
181
 
77
182
  ### 手机
@@ -80,7 +185,7 @@ termpilot agent --relay ws://127.0.0.1:8787/ws
80
185
 
81
186
  - `https://your-domain.com`
82
187
 
83
- 首次使用时,直接执行上面的 `termpilot agent --relay ...` 就会拿到配对码;`termpilot pair` 现在只是补充入口,用于你已经有后台 agent、但想重新生成一次配对码的场景。
188
+ 首次使用时,直接执行上面的 `termpilot agent` 就会进入配置引导并拿到配对码;如果你已经跑着后台 agent、只是想重新给手机配对,用 `termpilot agent --pair`。
84
189
 
85
190
  配对成功后:
86
191
 
@@ -89,29 +194,24 @@ termpilot agent --relay ws://127.0.0.1:8787/ws
89
194
  - 点进一个会话后才进入终端详情页
90
195
  - 连接信息和设备设置都在页面底部折叠区
91
196
 
92
- ## 最短使用路径
93
-
94
- 电脑上直接启动后台 agent:
95
-
96
- ```bash
97
- termpilot agent --relay ws://your-domain.com/ws
98
- ```
197
+ ## 日常使用
99
198
 
100
- 拿到配对码以后,在手机上完成配对。然后你日常最简单的启动方式就是:
199
+ ### 直接把命令交给 TermPilot
101
200
 
102
201
  ```bash
202
+ termpilot agent
103
203
  termpilot claude code
204
+ termpilot open code
104
205
  ```
105
206
 
106
- 或者:
207
+ 如果你想跑别的命令,也可以直接:
107
208
 
108
209
  ```bash
109
- termpilot open code
210
+ termpilot npm run dev
211
+ termpilot python worker.py
110
212
  ```
111
213
 
112
- 这会直接创建一个受 TermPilot 管理的 tmux 会话,并在当前终端里 attach 进去。手机上会同步看到同一个会话。
113
-
114
- ## 日常使用
214
+ ### 手动管理会话
115
215
 
116
216
  创建会话并进入:
117
217
 
@@ -121,13 +221,7 @@ termpilot list
121
221
  termpilot attach --sid <sid>
122
222
  ```
123
223
 
124
- 如果你不想手动 `create + attach`,可以直接把命令交给 TermPilot:
125
-
126
- ```bash
127
- termpilot claude code
128
- ```
129
-
130
- 在会话里运行:
224
+ 进入会话以后,你仍然可以自己手动运行:
131
225
 
132
226
  ```bash
133
227
  claude code
@@ -141,10 +235,12 @@ open code
141
235
 
142
236
  ```bash
143
237
  termpilot relay
144
- termpilot agent --relay ws://127.0.0.1:8787/ws
238
+ termpilot relay stop
239
+ termpilot relay run
240
+ termpilot agent
241
+ termpilot agent --pair
145
242
  termpilot agent status
146
243
  termpilot agent stop
147
- termpilot pair
148
244
  termpilot create --name claude-main
149
245
  termpilot list
150
246
  termpilot attach --sid <sid>
@@ -158,12 +254,24 @@ termpilot doctor
158
254
  ## 最佳实践
159
255
 
160
256
  1. 需要跨端同步的任务,一开始就用 `termpilot create` 创建,不要先在普通终端里跑再想着接管。
161
- 2. 一个长期任务用一个独立会话,名称直接写任务语义,比如 `claude-main`、`deploy-watch`。
162
- 3. 电脑前重操作优先 `termpilot attach`,手机更适合看进度、补命令和关闭会话。
163
- 4. 手机优先走一次性配对码,不要长期依赖共享 `client token`。
164
- 5. 要长期使用 relay,优先接 PostgreSQL;本地演示可以先用内存模式。
165
- 6. 换手机或访问权变更时,先 `termpilot grants`,再 `termpilot revoke --token ...`。
166
- 7. 想排查控制历史时先看 `termpilot audit --limit 30`。
257
+ 2. 第一次先跑一次 `termpilot agent` 完成本机配置,之后日常就只需要记住这一条命令。
258
+ 3. 如果只是想“开一个会话然后立刻跑起来”,优先用 `termpilot claude code` 这类直达命令,不必手动 `create + attach`。
259
+ 4. 一个长期任务用一个独立会话,名称直接写任务语义,比如 `claude-main`、`deploy-watch`、`batch-fix`。
260
+ 5. 电脑前重操作优先 `termpilot attach`;手机更适合看进度、发短命令、补快捷键和关闭会话。
261
+ 6. 普通 iTerm / Terminal 标签页不是 TermPilot 管理对象,不要指望后面“无缝接管”进来。
262
+ 7. 手机优先走一次性配对码,不要长期传播访问令牌。
263
+ 8. 要长期使用 relay,优先放到 HTTPS/WSS 域名后面,并接 PostgreSQL;本地演示可以先用内存模式。
264
+ 9. 换手机或访问权变更时,先 `termpilot grants`,再 `termpilot revoke --token ...`。
265
+ 10. 想排查控制历史时先看 `termpilot audit --limit 30`。
266
+ 11. 服务器上日常用 `termpilot relay` 后台运行;只有排查问题时才用 `termpilot relay run`。
267
+
268
+ ## 常见坑
269
+
270
+ - `termpilot agent` 不会停在前台,这是正常的;它默认就是后台守护进程。
271
+ - `termpilot relay` 默认也不会停在前台;想看日志请用 `termpilot relay run`。
272
+ - 手机上看不到任务时,先确认这个任务是不是通过 `termpilot ...` 或 `termpilot create` 启动的。
273
+ - 首次配对优先用 `termpilot agent` 拿配对码;重新给手机配对时用 `termpilot agent --pair`。
274
+ - 外网正式使用时,不要长期直接裸奔 `ws://IP:8787/ws`,最好上域名和反代。
167
275
 
168
276
  ## 本地开发
169
277
 
package/dist/cli.js CHANGED
@@ -1,13 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli.ts
4
- import path3 from "path";
5
- import { fileURLToPath as fileURLToPath2 } from "url";
6
-
7
3
  // agent/src/cli.ts
8
4
  import { spawn as spawn2 } from "child_process";
9
5
  import { openSync as openSync2 } from "fs";
10
6
  import { cwd as processCwd2 } from "process";
7
+ import { createInterface } from "readline/promises";
11
8
  import { setTimeout as delay2 } from "timers/promises";
12
9
 
13
10
  // agent/src/daemon.ts
@@ -53,6 +50,9 @@ function getAgentRuntimeFilePath() {
53
50
  function getAgentLogFilePath() {
54
51
  return path.join(getAgentHome(), "agent.log");
55
52
  }
53
+ function getAgentConfigFilePath() {
54
+ return path.join(getAgentHome(), "config.json");
55
+ }
56
56
  function getStateLockPath() {
57
57
  return `${getStateFilePath()}.lock`;
58
58
  }
@@ -175,6 +175,29 @@ function clearAgentRuntime(expectedPid) {
175
175
  }
176
176
  rmSync(getAgentRuntimeFilePath(), { force: true });
177
177
  }
178
+ function loadAgentConfig() {
179
+ ensureAgentHome();
180
+ try {
181
+ const raw = readFileSync(getAgentConfigFilePath(), "utf8");
182
+ const parsed = JSON.parse(raw);
183
+ if (typeof parsed.relayUrl !== "string" || typeof parsed.deviceId !== "string") {
184
+ return null;
185
+ }
186
+ const relayUrl = parsed.relayUrl.trim();
187
+ const deviceId = parsed.deviceId.trim();
188
+ if (!relayUrl || !deviceId) {
189
+ return null;
190
+ }
191
+ return { relayUrl, deviceId };
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+ function saveAgentConfig(config) {
197
+ ensureAgentHome();
198
+ writeFileSync(getAgentConfigFilePath(), `${JSON.stringify(config, null, 2)}
199
+ `, "utf8");
200
+ }
178
201
 
179
202
  // agent/src/tmux-backend.ts
180
203
  import { randomUUID as randomUUID2 } from "crypto";
@@ -716,6 +739,7 @@ function printHelp() {
716
739
  console.log(`TermPilot agent \u7528\u6CD5\uFF1A
717
740
 
718
741
  termpilot agent
742
+ termpilot agent --pair
719
743
  termpilot agent --foreground
720
744
  termpilot agent status
721
745
  termpilot agent stop
@@ -813,10 +837,130 @@ async function runDoctor() {
813
837
  }
814
838
  function getDeviceId(argv) {
815
839
  const args = parseArgs(argv);
816
- return resolveDeviceId(typeof args.deviceId === "string" ? args.deviceId : void 0);
840
+ const explicitDeviceId = typeof args.deviceId === "string" ? args.deviceId : void 0;
841
+ if (explicitDeviceId) {
842
+ return resolveDeviceId(explicitDeviceId);
843
+ }
844
+ const saved = loadAgentConfig();
845
+ return resolveDeviceId(saved?.deviceId);
846
+ }
847
+ function isLocalRelayHost(hostname) {
848
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || /^10\./.test(hostname) || /^192\.168\./.test(hostname) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
849
+ }
850
+ function normalizeRelayUrl(rawHost, rawPort) {
851
+ const hostInput = rawHost.trim();
852
+ const portInput = rawPort.trim() || "8787";
853
+ const normalizedPort = Number(portInput);
854
+ if (!Number.isFinite(normalizedPort) || normalizedPort <= 0 || normalizedPort > 65535) {
855
+ throw new Error("\u7AEF\u53E3\u65E0\u6548\uFF0C\u8BF7\u8F93\u5165 1 \u5230 65535 \u4E4B\u95F4\u7684\u6570\u5B57\u3002");
856
+ }
857
+ if (hostInput.includes("://")) {
858
+ const parsed = new URL(hostInput);
859
+ if (parsed.protocol === "http:") {
860
+ parsed.protocol = "ws:";
861
+ } else if (parsed.protocol === "https:") {
862
+ parsed.protocol = "wss:";
863
+ }
864
+ if (!parsed.port) {
865
+ parsed.port = String(normalizedPort);
866
+ }
867
+ if (!parsed.pathname || parsed.pathname === "/") {
868
+ parsed.pathname = "/ws";
869
+ }
870
+ parsed.search = "";
871
+ parsed.hash = "";
872
+ return parsed.toString();
873
+ }
874
+ const protocol = isLocalRelayHost(hostInput) ? "ws:" : "wss:";
875
+ return `${protocol}//${hostInput}:${normalizedPort}/ws`;
876
+ }
877
+ async function promptForAgentConfig(deviceId) {
878
+ const rl = createInterface({
879
+ input: process.stdin,
880
+ output: process.stdout
881
+ });
882
+ try {
883
+ console.log("\u8FD8\u6CA1\u6709\u627E\u5230\u672C\u673A\u7684 relay \u914D\u7F6E\uFF0C\u5148\u505A\u4E00\u6B21\u521D\u59CB\u5316\u3002");
884
+ const host = (await rl.question("\u8BF7\u8F93\u5165 relay \u57DF\u540D\u6216 IP: ")).trim();
885
+ if (!host) {
886
+ throw new Error("\u672A\u8F93\u5165 relay \u57DF\u540D\u6216 IP\uFF0C\u5DF2\u53D6\u6D88\u3002");
887
+ }
888
+ const port = await rl.question("\u8BF7\u8F93\u5165 relay \u7AEF\u53E3\uFF08\u76F4\u63A5\u56DE\u8F66\u9ED8\u8BA4 8787\uFF09: ");
889
+ const relayUrl = normalizeRelayUrl(host, port);
890
+ console.log(`\u5C06\u4F7F\u7528 relay: ${relayUrl}`);
891
+ return { relayUrl, deviceId };
892
+ } finally {
893
+ rl.close();
894
+ }
817
895
  }
818
- function getRelayUrl() {
819
- return process.env.TERMPILOT_RELAY_URL ?? "ws://127.0.0.1:8787/ws";
896
+ function getResolvedConfig(argv) {
897
+ const args = parseArgs(argv);
898
+ const deviceId = getDeviceId(argv);
899
+ const cliRelayUrl = typeof args.relay === "string" ? args.relay.trim() : "";
900
+ if (cliRelayUrl) {
901
+ return {
902
+ source: "cli",
903
+ config: {
904
+ relayUrl: cliRelayUrl,
905
+ deviceId
906
+ }
907
+ };
908
+ }
909
+ const envRelayUrl = process.env.TERMPILOT_RELAY_URL?.trim();
910
+ if (envRelayUrl) {
911
+ return {
912
+ source: "env",
913
+ config: {
914
+ relayUrl: envRelayUrl,
915
+ deviceId
916
+ }
917
+ };
918
+ }
919
+ const saved = loadAgentConfig();
920
+ if (saved) {
921
+ return {
922
+ source: "saved",
923
+ config: {
924
+ relayUrl: saved.relayUrl,
925
+ deviceId
926
+ }
927
+ };
928
+ }
929
+ return null;
930
+ }
931
+ async function ensureConfigured(argv) {
932
+ const resolved = getResolvedConfig(argv);
933
+ if (resolved) {
934
+ return resolved;
935
+ }
936
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
937
+ throw new Error(`\u8FD8\u6CA1\u6709\u914D\u7F6E relay\uFF0C\u8BF7\u5148\u6267\u884C\uFF1Atermpilot agent --relay wss://\u4F60\u7684\u57DF\u540D/ws\uFF0C\u6216\u5728\u4EA4\u4E92\u7EC8\u7AEF\u91CC\u76F4\u63A5\u8FD0\u884C termpilot agent\u3002`);
938
+ }
939
+ const config = await promptForAgentConfig(getDeviceId(argv));
940
+ saveAgentConfig(config);
941
+ return { config, source: "prompt" };
942
+ }
943
+ function applyAgentConfig(config) {
944
+ process.env.TERMPILOT_RELAY_URL = config.relayUrl;
945
+ process.env.TERMPILOT_DEVICE_ID = config.deviceId;
946
+ }
947
+ function printRuntimeStatus(runtime = readRuntimeStatus().runtime) {
948
+ if (!runtime) {
949
+ console.log("\u540E\u53F0 agent \u5F53\u524D\u672A\u8FD0\u884C\u3002");
950
+ console.log(`\u72B6\u6001\u76EE\u5F55: ${getAgentHome()}`);
951
+ console.log(`\u914D\u7F6E\u6587\u4EF6: ${getAgentConfigFilePath()}`);
952
+ console.log(`\u65E5\u5FD7: ${getAgentLogFilePath()}`);
953
+ return;
954
+ }
955
+ const sessions = loadState().sessions.filter((session) => session.deviceId === runtime.deviceId);
956
+ const runningSessions = sessions.filter((session) => session.status === "running").length;
957
+ console.log("\u540E\u53F0 agent \u6B63\u5728\u8FD0\u884C\u3002");
958
+ console.log(`PID: ${runtime.pid}`);
959
+ console.log(`\u8BBE\u5907: ${runtime.deviceId}`);
960
+ console.log(`relay: ${runtime.relayUrl}`);
961
+ console.log(`\u542F\u52A8\u65F6\u95F4: ${runtime.startedAt}`);
962
+ console.log(`\u65E5\u5FD7: ${getAgentLogFilePath()}`);
963
+ console.log(`\u4F1A\u8BDD: ${runningSessions} \u4E2A\u8FD0\u884C\u4E2D / ${sessions.length} \u4E2A\u603B\u8BA1`);
820
964
  }
821
965
  function isProcessAlive(pid) {
822
966
  try {
@@ -855,23 +999,37 @@ async function waitForPairingCode(deviceId) {
855
999
  }
856
1000
  async function runStart(argv) {
857
1001
  const args = parseArgs(argv);
1002
+ const shouldPair = Boolean(args.pair);
1003
+ const { config, source } = await ensureConfigured(argv);
1004
+ applyAgentConfig(config);
1005
+ if (source === "cli" || source === "prompt") {
1006
+ saveAgentConfig(config);
1007
+ }
858
1008
  if (args.foreground) {
859
1009
  await runDaemon();
860
1010
  return;
861
1011
  }
862
- const deviceId = getDeviceId(argv);
863
- const relayUrl = getRelayUrl();
1012
+ const deviceId = config.deviceId;
1013
+ const relayUrl = config.relayUrl;
864
1014
  const existing = readRuntimeStatus();
865
1015
  if (existing.runtime && existing.alive) {
866
- console.log(`\u540E\u53F0 agent \u5DF2\u5728\u8FD0\u884C\uFF0CPID: ${existing.runtime.pid}`);
867
- console.log(`\u8BBE\u5907: ${existing.runtime.deviceId}`);
868
- console.log(`relay: ${existing.runtime.relayUrl}`);
869
- const pairing2 = await waitForPairingCode(deviceId);
870
- if (pairing2) {
871
- console.log(`\u914D\u5BF9\u7801: ${pairing2.pairingCode}`);
872
- console.log(`\u6709\u6548\u671F\u81F3: ${pairing2.expiresAt}`);
1016
+ const sameRuntime = existing.runtime.relayUrl === relayUrl && existing.runtime.deviceId === deviceId;
1017
+ if (!sameRuntime) {
1018
+ console.log("\u68C0\u6D4B\u5230\u540E\u53F0 agent \u5DF2\u5728\u8FD0\u884C\uFF0C\u4F46\u914D\u7F6E\u548C\u5F53\u524D\u547D\u4EE4\u4E0D\u4E00\u81F4\uFF0C\u6B63\u5728\u91CD\u542F\u3002");
1019
+ await runStop();
1020
+ } else {
1021
+ printRuntimeStatus(existing.runtime);
1022
+ if (shouldPair) {
1023
+ const pairing = await waitForPairingCode(deviceId);
1024
+ if (pairing) {
1025
+ console.log(`\u914D\u5BF9\u7801: ${pairing.pairingCode}`);
1026
+ console.log(`\u6709\u6548\u671F\u81F3: ${pairing.expiresAt}`);
1027
+ }
1028
+ } else {
1029
+ console.log("\u5982\u9700\u91CD\u65B0\u7ED9\u624B\u673A\u914D\u5BF9\uFF0C\u8BF7\u6267\u884C\uFF1Atermpilot agent --pair");
1030
+ }
1031
+ return;
873
1032
  }
874
- return;
875
1033
  }
876
1034
  clearAgentRuntime();
877
1035
  const logFilePath = getAgentLogFilePath();
@@ -895,11 +1053,18 @@ async function runStart(argv) {
895
1053
  console.log(`\u8BBE\u5907: ${deviceId}`);
896
1054
  console.log(`relay: ${relayUrl}`);
897
1055
  console.log(`\u65E5\u5FD7: ${logFilePath}`);
898
- const pairing = await waitForPairingCode(deviceId);
899
- if (pairing) {
900
- console.log(`\u914D\u5BF9\u7801: ${pairing.pairingCode}`);
901
- console.log(`\u6709\u6548\u671F\u81F3: ${pairing.expiresAt}`);
902
- console.log("\u624B\u673A\u7AEF\u76F4\u63A5\u6253\u5F00 relay \u9875\u9762\u5E76\u8F93\u5165\u8FD9\u4E2A\u914D\u5BF9\u7801\u5373\u53EF\u3002");
1056
+ if (source === "prompt") {
1057
+ console.log("\u672C\u6B21 relay \u914D\u7F6E\u5DF2\u4FDD\u5B58\u3002\u4EE5\u540E\u76F4\u63A5\u8FD0\u884C termpilot agent \u5373\u53EF\u3002");
1058
+ }
1059
+ if (shouldPair || source !== "saved") {
1060
+ const pairing = await waitForPairingCode(deviceId);
1061
+ if (pairing) {
1062
+ console.log(`\u914D\u5BF9\u7801: ${pairing.pairingCode}`);
1063
+ console.log(`\u6709\u6548\u671F\u81F3: ${pairing.expiresAt}`);
1064
+ console.log("\u624B\u673A\u7AEF\u76F4\u63A5\u6253\u5F00 relay \u9875\u9762\u5E76\u8F93\u5165\u8FD9\u4E2A\u914D\u5BF9\u7801\u5373\u53EF\u3002");
1065
+ }
1066
+ } else {
1067
+ console.log("\u5982\u9700\u91CD\u65B0\u7ED9\u624B\u673A\u914D\u5BF9\uFF0C\u8BF7\u6267\u884C\uFF1Atermpilot agent --pair");
903
1068
  }
904
1069
  }
905
1070
  function runStatus() {
@@ -907,18 +1072,21 @@ function runStatus() {
907
1072
  if (!runtime || !alive) {
908
1073
  console.log("\u540E\u53F0 agent \u5F53\u524D\u672A\u8FD0\u884C\u3002");
909
1074
  console.log(`\u72B6\u6001\u76EE\u5F55: ${getAgentHome()}`);
1075
+ console.log(`\u914D\u7F6E\u6587\u4EF6: ${getAgentConfigFilePath()}`);
910
1076
  console.log(`\u65E5\u5FD7: ${getAgentLogFilePath()}`);
1077
+ const config2 = loadAgentConfig();
1078
+ if (config2) {
1079
+ console.log(`\u5DF2\u4FDD\u5B58 relay: ${config2.relayUrl}`);
1080
+ console.log(`\u5DF2\u4FDD\u5B58\u8BBE\u5907: ${config2.deviceId}`);
1081
+ }
911
1082
  return;
912
1083
  }
913
- const sessions = loadState().sessions.filter((session) => session.deviceId === runtime.deviceId);
914
- const runningSessions = sessions.filter((session) => session.status === "running").length;
915
- console.log("\u540E\u53F0 agent \u6B63\u5728\u8FD0\u884C\u3002");
916
- console.log(`PID: ${runtime.pid}`);
917
- console.log(`\u8BBE\u5907: ${runtime.deviceId}`);
918
- console.log(`relay: ${runtime.relayUrl}`);
919
- console.log(`\u542F\u52A8\u65F6\u95F4: ${runtime.startedAt}`);
920
- console.log(`\u65E5\u5FD7: ${getAgentLogFilePath()}`);
921
- console.log(`\u4F1A\u8BDD: ${runningSessions} \u4E2A\u8FD0\u884C\u4E2D / ${sessions.length} \u4E2A\u603B\u8BA1`);
1084
+ printRuntimeStatus(runtime);
1085
+ const config = loadAgentConfig();
1086
+ if (config && (config.relayUrl !== runtime.relayUrl || config.deviceId !== runtime.deviceId)) {
1087
+ console.log(`\u5DF2\u4FDD\u5B58 relay: ${config.relayUrl}`);
1088
+ console.log(`\u5DF2\u4FDD\u5B58\u8BBE\u5907: ${config.deviceId}`);
1089
+ }
922
1090
  }
923
1091
  async function runStop() {
924
1092
  const { runtime, alive } = readRuntimeStatus();
@@ -962,8 +1130,10 @@ async function runManagedCommand(argv) {
962
1130
  }
963
1131
  async function runDaemon() {
964
1132
  await ensureTmuxAvailable();
965
- const relayUrl = getRelayUrl();
966
- const deviceId = resolveDeviceId();
1133
+ const config = await ensureConfigured([]);
1134
+ applyAgentConfig(config.config);
1135
+ const relayUrl = config.config.relayUrl;
1136
+ const deviceId = config.config.deviceId;
967
1137
  saveAgentRuntime({
968
1138
  pid: process.pid,
969
1139
  relayUrl,
@@ -985,6 +1155,8 @@ async function runDaemon() {
985
1155
  await daemon.start();
986
1156
  }
987
1157
  async function runPair(argv) {
1158
+ const config = await ensureConfigured(argv);
1159
+ applyAgentConfig(config.config);
988
1160
  const deviceId = getDeviceId(argv);
989
1161
  const payload = await createPairingCode(deviceId);
990
1162
  console.log(`\u8BBE\u5907: ${payload.deviceId}`);
@@ -993,6 +1165,8 @@ async function runPair(argv) {
993
1165
  console.log("\u8BF7\u5728\u624B\u673A\u7AEF\u8F93\u5165\u8FD9\u4E2A\u914D\u5BF9\u7801\uFF0C\u6362\u53D6\u8BBE\u5907\u8BBF\u95EE\u4EE4\u724C\u3002");
994
1166
  }
995
1167
  async function runGrants(argv) {
1168
+ const config = await ensureConfigured(argv);
1169
+ applyAgentConfig(config.config);
996
1170
  const deviceId = getDeviceId(argv);
997
1171
  const payload = await listDeviceGrants(deviceId);
998
1172
  if (payload.grants.length === 0) {
@@ -1013,12 +1187,16 @@ async function runRevoke(argv) {
1013
1187
  if (!accessToken) {
1014
1188
  throw new Error("\u8BF7\u901A\u8FC7 --token \u6307\u5B9A\u8981\u64A4\u9500\u7684\u8BBF\u95EE\u4EE4\u724C\u3002");
1015
1189
  }
1190
+ const config = await ensureConfigured(argv);
1191
+ applyAgentConfig(config.config);
1016
1192
  const deviceId = getDeviceId(argv);
1017
1193
  await revokeDeviceGrant(deviceId, accessToken);
1018
1194
  console.log(`\u5DF2\u64A4\u9500\u8BBE\u5907 ${deviceId} \u7684\u8BBF\u95EE\u4EE4\u724C ${accessToken}`);
1019
1195
  }
1020
1196
  async function runAudit(argv) {
1021
1197
  const args = parseArgs(argv);
1198
+ const config = await ensureConfigured(argv);
1199
+ applyAgentConfig(config.config);
1022
1200
  const deviceId = getDeviceId(argv);
1023
1201
  const parsedLimit = typeof args.limit === "string" ? Number(args.limit) : 20;
1024
1202
  if (!Number.isFinite(parsedLimit) || parsedLimit <= 0) {
@@ -1099,9 +1277,75 @@ async function runAgentCli(argv = process.argv.slice(2)) {
1099
1277
  }
1100
1278
  }
1101
1279
 
1280
+ // relay/src/cli.ts
1281
+ import { spawn as spawn3 } from "child_process";
1282
+ import { openSync as openSync3 } from "fs";
1283
+ import { setTimeout as delay3 } from "timers/promises";
1284
+
1285
+ // relay/src/config.ts
1286
+ function loadConfig() {
1287
+ return {
1288
+ host: process.env.HOST ?? "0.0.0.0",
1289
+ port: Number(process.env.PORT ?? 8787),
1290
+ agentToken: process.env.TERMPILOT_AGENT_TOKEN ?? DEFAULT_AGENT_TOKEN,
1291
+ clientToken: process.env.TERMPILOT_CLIENT_TOKEN ?? DEFAULT_CLIENT_TOKEN,
1292
+ databaseUrl: process.env.DATABASE_URL?.trim() || void 0,
1293
+ pairingTtlMinutes: Number(process.env.TERMPILOT_PAIRING_TTL_MINUTES ?? 10)
1294
+ };
1295
+ }
1296
+
1297
+ // relay/src/runtime-store.ts
1298
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1299
+ import { homedir as homedir2 } from "os";
1300
+ import path2 from "path";
1301
+ function getRelayHome() {
1302
+ return process.env.TERMPILOT_HOME ?? path2.join(homedir2(), ".termpilot");
1303
+ }
1304
+ function ensureRelayHome() {
1305
+ const dir = getRelayHome();
1306
+ mkdirSync2(dir, { recursive: true });
1307
+ return dir;
1308
+ }
1309
+ function getRelayRuntimeFilePath() {
1310
+ return path2.join(getRelayHome(), "relay-runtime.json");
1311
+ }
1312
+ function getRelayLogFilePath() {
1313
+ return path2.join(getRelayHome(), "relay.log");
1314
+ }
1315
+ function loadRelayRuntime() {
1316
+ ensureRelayHome();
1317
+ try {
1318
+ const raw = readFileSync2(getRelayRuntimeFilePath(), "utf8");
1319
+ const parsed = JSON.parse(raw);
1320
+ if (typeof parsed.pid !== "number" || typeof parsed.host !== "string" || typeof parsed.port !== "number" || typeof parsed.startedAt !== "string") {
1321
+ return null;
1322
+ }
1323
+ return {
1324
+ pid: parsed.pid,
1325
+ host: parsed.host,
1326
+ port: parsed.port,
1327
+ startedAt: parsed.startedAt
1328
+ };
1329
+ } catch {
1330
+ return null;
1331
+ }
1332
+ }
1333
+ function saveRelayRuntime(runtime) {
1334
+ ensureRelayHome();
1335
+ writeFileSync2(getRelayRuntimeFilePath(), `${JSON.stringify(runtime, null, 2)}
1336
+ `, "utf8");
1337
+ }
1338
+ function clearRelayRuntime(expectedPid) {
1339
+ const current = loadRelayRuntime();
1340
+ if (expectedPid !== void 0 && current?.pid !== expectedPid) {
1341
+ return;
1342
+ }
1343
+ rmSync2(getRelayRuntimeFilePath(), { force: true });
1344
+ }
1345
+
1102
1346
  // relay/src/server.ts
1103
1347
  import { createReadStream, existsSync, statSync as statSync2 } from "fs";
1104
- import path2 from "path";
1348
+ import path3 from "path";
1105
1349
  import { fileURLToPath } from "url";
1106
1350
  import Fastify from "fastify";
1107
1351
  import websocket from "@fastify/websocket";
@@ -1403,18 +1647,6 @@ var PostgresAuditStore = class {
1403
1647
  }
1404
1648
  };
1405
1649
 
1406
- // relay/src/config.ts
1407
- function loadConfig() {
1408
- return {
1409
- host: process.env.HOST ?? "0.0.0.0",
1410
- port: Number(process.env.PORT ?? 8787),
1411
- agentToken: process.env.TERMPILOT_AGENT_TOKEN ?? DEFAULT_AGENT_TOKEN,
1412
- clientToken: process.env.TERMPILOT_CLIENT_TOKEN ?? DEFAULT_CLIENT_TOKEN,
1413
- databaseUrl: process.env.DATABASE_URL?.trim() || void 0,
1414
- pairingTtlMinutes: Number(process.env.TERMPILOT_PAIRING_TTL_MINUTES ?? 10)
1415
- };
1416
- }
1417
-
1418
1650
  // relay/src/session-store.ts
1419
1651
  var MemorySessionStore = class {
1420
1652
  mode = "memory";
@@ -1588,24 +1820,24 @@ var STATIC_CONTENT_TYPES = {
1588
1820
  ".webmanifest": "application/manifest+json; charset=utf-8"
1589
1821
  };
1590
1822
  function getMimeType(filePath) {
1591
- return STATIC_CONTENT_TYPES[path2.extname(filePath).toLowerCase()] ?? "application/octet-stream";
1823
+ return STATIC_CONTENT_TYPES[path3.extname(filePath).toLowerCase()] ?? "application/octet-stream";
1592
1824
  }
1593
1825
  function createStaticPath(webDir, urlPath) {
1594
1826
  const requestPath = decodeURIComponent(urlPath.split("?")[0] ?? "/");
1595
1827
  const relativePath = requestPath === "/" ? "index.html" : requestPath.replace(/^\/+/, "");
1596
- const resolvedPath = path2.resolve(webDir, relativePath);
1597
- if (!resolvedPath.startsWith(path2.resolve(webDir))) {
1598
- return path2.join(webDir, "index.html");
1828
+ const resolvedPath = path3.resolve(webDir, relativePath);
1829
+ if (!resolvedPath.startsWith(path3.resolve(webDir))) {
1830
+ return path3.join(webDir, "index.html");
1599
1831
  }
1600
1832
  if (!existsSync(resolvedPath)) {
1601
- return path2.join(webDir, "index.html");
1833
+ return path3.join(webDir, "index.html");
1602
1834
  }
1603
1835
  try {
1604
1836
  if (statSync2(resolvedPath).isDirectory()) {
1605
- return path2.join(webDir, "index.html");
1837
+ return path3.join(webDir, "index.html");
1606
1838
  }
1607
1839
  } catch {
1608
- return path2.join(webDir, "index.html");
1840
+ return path3.join(webDir, "index.html");
1609
1841
  }
1610
1842
  return resolvedPath;
1611
1843
  }
@@ -2080,6 +2312,129 @@ async function startRelayServer(options = {}) {
2080
2312
  return app;
2081
2313
  }
2082
2314
 
2315
+ // relay/src/cli.ts
2316
+ function isProcessAlive2(pid) {
2317
+ try {
2318
+ process.kill(pid, 0);
2319
+ return true;
2320
+ } catch {
2321
+ return false;
2322
+ }
2323
+ }
2324
+ function readRuntimeStatus2() {
2325
+ const runtime = loadRelayRuntime();
2326
+ if (!runtime) {
2327
+ return { runtime: null, alive: false };
2328
+ }
2329
+ const alive = isProcessAlive2(runtime.pid);
2330
+ if (!alive) {
2331
+ clearRelayRuntime(runtime.pid);
2332
+ return { runtime: null, alive: false };
2333
+ }
2334
+ return { runtime, alive };
2335
+ }
2336
+ function printRuntime(runtime = readRuntimeStatus2().runtime) {
2337
+ if (!runtime) {
2338
+ console.log("\u540E\u53F0 relay \u5F53\u524D\u672A\u8FD0\u884C\u3002");
2339
+ console.log(`\u8FD0\u884C\u65F6\u6587\u4EF6: ${getRelayRuntimeFilePath()}`);
2340
+ console.log(`\u65E5\u5FD7: ${getRelayLogFilePath()}`);
2341
+ return;
2342
+ }
2343
+ console.log("\u540E\u53F0 relay \u6B63\u5728\u8FD0\u884C\u3002");
2344
+ console.log(`PID: ${runtime.pid}`);
2345
+ console.log(`\u76D1\u542C: http://${runtime.host}:${runtime.port}`);
2346
+ console.log(`\u542F\u52A8\u65F6\u95F4: ${runtime.startedAt}`);
2347
+ console.log(`\u65E5\u5FD7: ${getRelayLogFilePath()}`);
2348
+ }
2349
+ async function runForeground() {
2350
+ const config = loadConfig();
2351
+ saveRelayRuntime({
2352
+ pid: process.pid,
2353
+ host: config.host,
2354
+ port: config.port,
2355
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2356
+ });
2357
+ process.on("exit", () => {
2358
+ clearRelayRuntime(process.pid);
2359
+ });
2360
+ await startRelayServer({ webDir: resolveDefaultWebDir(import.meta.url), config });
2361
+ await new Promise(() => {
2362
+ });
2363
+ }
2364
+ async function runStart2() {
2365
+ const config = loadConfig();
2366
+ const existing = readRuntimeStatus2();
2367
+ if (existing.runtime && existing.alive) {
2368
+ const sameConfig = existing.runtime.host === config.host && existing.runtime.port === config.port;
2369
+ if (sameConfig) {
2370
+ printRuntime(existing.runtime);
2371
+ return;
2372
+ }
2373
+ console.log("\u68C0\u6D4B\u5230\u540E\u53F0 relay \u5DF2\u5728\u8FD0\u884C\uFF0C\u4F46\u76D1\u542C\u914D\u7F6E\u548C\u5F53\u524D\u547D\u4EE4\u4E0D\u4E00\u81F4\uFF0C\u6B63\u5728\u91CD\u542F\u3002");
2374
+ await runStop2();
2375
+ }
2376
+ clearRelayRuntime();
2377
+ const logFilePath = getRelayLogFilePath();
2378
+ const logFd = openSync3(logFilePath, "a");
2379
+ const child = spawn3(process.execPath, [process.argv[1], "relay-daemon"], {
2380
+ detached: true,
2381
+ stdio: ["ignore", logFd, logFd],
2382
+ env: process.env
2383
+ });
2384
+ child.unref();
2385
+ if (!child.pid) {
2386
+ throw new Error("\u540E\u53F0 relay \u542F\u52A8\u5931\u8D25\uFF0C\u672A\u83B7\u53D6\u5230\u5B50\u8FDB\u7A0B PID\u3002");
2387
+ }
2388
+ saveRelayRuntime({
2389
+ pid: child.pid,
2390
+ host: config.host,
2391
+ port: config.port,
2392
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2393
+ });
2394
+ console.log(`\u540E\u53F0 relay \u5DF2\u542F\u52A8\uFF0CPID: ${child.pid}`);
2395
+ console.log(`\u76D1\u542C: http://${config.host}:${config.port}`);
2396
+ console.log(`\u65E5\u5FD7: ${logFilePath}`);
2397
+ await delay3(300);
2398
+ }
2399
+ async function runStop2() {
2400
+ const { runtime, alive } = readRuntimeStatus2();
2401
+ if (!runtime || !alive) {
2402
+ console.log("\u540E\u53F0 relay \u5F53\u524D\u672A\u8FD0\u884C\u3002");
2403
+ clearRelayRuntime();
2404
+ return;
2405
+ }
2406
+ process.kill(runtime.pid, "SIGTERM");
2407
+ for (let attempt = 0; attempt < 20; attempt += 1) {
2408
+ if (!isProcessAlive2(runtime.pid)) {
2409
+ clearRelayRuntime(runtime.pid);
2410
+ console.log(`\u540E\u53F0 relay \u5DF2\u505C\u6B62\uFF0CPID: ${runtime.pid}`);
2411
+ return;
2412
+ }
2413
+ await delay3(100);
2414
+ }
2415
+ process.kill(runtime.pid, "SIGKILL");
2416
+ clearRelayRuntime(runtime.pid);
2417
+ console.log(`\u540E\u53F0 relay \u5DF2\u5F3A\u5236\u505C\u6B62\uFF0CPID: ${runtime.pid}`);
2418
+ }
2419
+ async function runRelayCli(argv = process.argv.slice(2)) {
2420
+ const [command] = argv;
2421
+ if (!command || command === "start") {
2422
+ await runStart2();
2423
+ return;
2424
+ }
2425
+ switch (command) {
2426
+ case "run":
2427
+ case "daemon":
2428
+ await runForeground();
2429
+ return;
2430
+ case "stop":
2431
+ await runStop2();
2432
+ return;
2433
+ default:
2434
+ throw new Error(`\u672A\u77E5 relay \u5B50\u547D\u4EE4: ${command}`);
2435
+ }
2436
+ }
2437
+
2083
2438
  // src/cli.ts
2084
2439
  var AGENT_ENV_FLAGS = [
2085
2440
  { flag: "--relay", envName: "TERMPILOT_RELAY_URL" },
@@ -2099,8 +2454,11 @@ var RELAY_ENV_FLAGS = [
2099
2454
  function printHelp2() {
2100
2455
  console.log(`TermPilot \u7528\u6CD5\uFF1A
2101
2456
 
2102
- termpilot relay [--host 0.0.0.0] [--port 8787]
2103
- termpilot agent [--relay ws://127.0.0.1:8787/ws] [--device-id pc-main]
2457
+ termpilot relay
2458
+ termpilot relay start
2459
+ termpilot relay stop
2460
+ termpilot relay run
2461
+ termpilot agent [--pair] [--relay ws://127.0.0.1:8787/ws] [--device-id pc-main]
2104
2462
  termpilot agent status
2105
2463
  termpilot agent stop
2106
2464
  termpilot claude code
@@ -2135,9 +2493,6 @@ function applyEnvFlags(argv, mappings) {
2135
2493
  }
2136
2494
  return rest;
2137
2495
  }
2138
- function resolveBundledWebDir() {
2139
- return path3.resolve(path3.dirname(fileURLToPath2(import.meta.url)), "../app/dist");
2140
- }
2141
2496
  async function main(argv = process.argv.slice(2)) {
2142
2497
  const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
2143
2498
  const [command, ...rest] = normalizedArgv;
@@ -2152,7 +2507,10 @@ async function main(argv = process.argv.slice(2)) {
2152
2507
  printHelp2();
2153
2508
  return;
2154
2509
  }
2155
- await startRelayServer({ webDir: resolveBundledWebDir() });
2510
+ if (relayArgs[0] === "status") {
2511
+ throw new Error("\u672A\u77E5 relay \u5B50\u547D\u4EE4: status");
2512
+ }
2513
+ await runRelayCli(relayArgs);
2156
2514
  return;
2157
2515
  }
2158
2516
  case "agent": {
@@ -2168,6 +2526,10 @@ async function main(argv = process.argv.slice(2)) {
2168
2526
  await runAgentCli(["daemon", ...rest]);
2169
2527
  return;
2170
2528
  }
2529
+ case "relay-daemon": {
2530
+ await runRelayCli(["daemon", ...rest]);
2531
+ return;
2532
+ }
2171
2533
  case "pair":
2172
2534
  case "create":
2173
2535
  case "list":
package/docs/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # 文档索引
2
2
 
3
3
  - [README.md](/Users/fengye/workspace/TermPilot/README.md):用户安装、启动和日常使用
4
+ - [operations-guide.md](/Users/fengye/workspace/TermPilot/docs/operations-guide.md):部署、反代、运维和排障手册
4
5
  - [development.md](/Users/fengye/workspace/TermPilot/docs/development.md):仓库开发、测试和发布流程
5
6
  - [architecture.md](/Users/fengye/workspace/TermPilot/docs/architecture.md):当前代码架构和运行时数据流
6
7
  - [protocol.md](/Users/fengye/workspace/TermPilot/docs/protocol.md):三端协议和 HTTP 接口
@@ -91,8 +91,10 @@ PC 端常驻进程和本地命令实现:
91
91
 
92
92
  1. 服务器执行 `termpilot relay`
93
93
  2. relay 监听 HTTP/WebSocket,并托管 `app/dist`
94
- 3. 电脑执行 `termpilot agent --relay ...`
95
- 4. 手机上直接打开 relay 域名
94
+ 3. 电脑第一次执行 `termpilot agent`,在终端里输入 relay 域名和端口
95
+ 4. agent 保存本地配置,并在后台启动常驻进程
96
+ 5. 以后电脑直接执行 `termpilot agent`,自动按已保存配置启动或显示状态
97
+ 6. 手机上直接打开 relay 域名
96
98
 
97
99
  ### 会话创建
98
100
 
@@ -112,11 +114,12 @@ PC 端常驻进程和本地命令实现:
112
114
 
113
115
  ### 配对与访问控制
114
116
 
115
- 1. 电脑端执行 `termpilot agent --relay ws://你的 relay 地址`
116
- 2. relay 创建一次性配对码
117
- 3. 手机端输入配对码,兑换设备访问令牌
118
- 4. client WebSocket 以后携带设备令牌
119
- 5. relay 只向该 client 暴露允许访问的设备和会话
117
+ 1. 电脑端执行 `termpilot agent`
118
+ 2. 如果本机还没有配置 relay,agent 会提示输入域名和端口,并保存到本地配置文件
119
+ 3. relay 创建一次性配对码
120
+ 4. 手机端输入配对码,兑换设备访问令牌
121
+ 5. client WebSocket 以后携带设备令牌
122
+ 6. relay 只向该 client 暴露允许访问的设备和会话
120
123
 
121
124
  ## 4. 当前实现边界
122
125
 
@@ -0,0 +1,445 @@
1
+ # TermPilot 部署与运维指南
2
+
3
+ 这份文档面向准备长期使用 TermPilot 的用户。它不重复 `README` 里的 5 分钟快速上手,而是把部署、反代、日常运维、排障和安全边界收成一份更完整的运行手册。
4
+
5
+ 文档风格参考了成熟开源项目常见的写法:先说明适用场景,再给推荐拓扑、部署步骤、运维动作、排障清单和安全边界。你可以把它当成 TermPilot 的管理员手册来用。
6
+
7
+ ## 0. 阅读这份文档前,你应该已经知道什么
8
+
9
+ 建议你已经完成过下面这件事中的至少一件:
10
+
11
+ - 在本地把 `termpilot relay`、`termpilot agent` 跑通过一次
12
+ - 已经看过 [README.md](/Users/fengye/workspace/TermPilot/README.md) 里的快速上手
13
+
14
+ 如果你还没有跑通过最小链路,请先回到 [README.md](/Users/fengye/workspace/TermPilot/README.md)。
15
+
16
+ ## 1. 适用场景
17
+
18
+ 适合下面这些情况:
19
+
20
+ - 你已经确认 TermPilot 的基本流程可用,准备长期跑在自己的服务器上
21
+ - 你希望用域名和 HTTPS/WSS 暴露 relay,而不是直接用裸 IP 和端口
22
+ - 你需要给自己或团队整理一份可维护的运行说明
23
+
24
+ 不适合下面这些情况:
25
+
26
+ - 第一次体验产品
27
+ - 只想在局域网里临时试一下
28
+
29
+ 第一次使用请先看 [README.md](/Users/fengye/workspace/TermPilot/README.md)。
30
+
31
+ ## 2. 部署清单
32
+
33
+ 开始之前,先确认下面这些前置条件:
34
+
35
+ - 一台能被手机访问到的服务器
36
+ - 一个已经解析到服务器的域名
37
+ - 服务器已经放行 `80` 和 `443`
38
+ - 电脑端已经安装 `tmux`
39
+ - 服务器和电脑都已经安装 `@fengye404/termpilot`
40
+
41
+ 推荐你按这个顺序推进:
42
+
43
+ 1. 先让服务器上的 `termpilot relay` 跑起来
44
+ 2. 再让域名和 HTTPS 反代跑起来
45
+ 3. 再在电脑执行 `termpilot agent`
46
+ 4. 最后用手机完成第一次配对
47
+
48
+ ## 3. 运行模型
49
+
50
+ TermPilot 由三部分组成:
51
+
52
+ - `relay`:运行在云服务器上,负责网页托管、WebSocket 中继、配对、设备权限和会话元数据
53
+ - `agent`:运行在你的电脑上,负责管理本地 `tmux` 会话并连接 relay
54
+ - `app`:手机浏览器直接打开 relay 域名,不需要单独安装 App
55
+
56
+ 推荐拓扑:
57
+
58
+ ```text
59
+ 手机浏览器 --https/wss--> 域名 / 反向代理 --> relay
60
+ ^
61
+ |
62
+ agent --wss--> /ws
63
+ ```
64
+
65
+ ## 4. 推荐部署模式
66
+
67
+ ### 模式 A:最低成本验证
68
+
69
+ - 服务器上直接运行 `termpilot relay`
70
+ - 对外暴露 `8787`
71
+ - 手机访问 `http://your-ip:8787`
72
+ - 电脑连接 `ws://your-ip:8787/ws`
73
+
74
+ 适合:
75
+
76
+ - 自己先试通链路
77
+ - 不想先配置域名和 HTTPS
78
+
79
+ 缺点:
80
+
81
+ - 没有 HTTPS/WSS
82
+ - 不适合长期使用
83
+
84
+ ### 模式 B:推荐生产模式
85
+
86
+ - 服务器上运行 `termpilot relay`
87
+ - 前面放一个反向代理,例如 Caddy
88
+ - 域名直接指向服务器
89
+ - 手机访问 `https://your-domain.com`
90
+ - 电脑连接 `wss://your-domain.com/ws`
91
+
92
+ 适合:
93
+
94
+ - 个人长期使用
95
+ - 多设备跨网络访问
96
+ - 想降低手机端访问阻力
97
+
98
+ ## 5. 生产部署步骤
99
+
100
+ ### 5.1 域名解析
101
+
102
+ 把你的域名 A 记录指向服务器公网 IP,例如:
103
+
104
+ - `fengye404.top -> 你的服务器公网 IP`
105
+
106
+ ### 5.2 服务器启动 relay
107
+
108
+ 最简单的后台启动:
109
+
110
+ ```bash
111
+ termpilot relay
112
+ ```
113
+
114
+ 常用命令:
115
+
116
+ ```bash
117
+ termpilot relay
118
+ termpilot relay stop
119
+ termpilot relay run
120
+ ```
121
+
122
+ 说明:
123
+
124
+ - `termpilot relay` 或 `termpilot relay start`:后台启动
125
+ - `termpilot relay stop`:停止后台 relay
126
+ - `termpilot relay run`:前台运行,适合看日志
127
+
128
+ 默认监听:
129
+
130
+ - `host=0.0.0.0`
131
+ - `port=8787`
132
+
133
+ ### 5.3 反向代理
134
+
135
+ 推荐用 Caddy。最小配置如下:
136
+
137
+ ```caddyfile
138
+ fengye404.top {
139
+ reverse_proxy 127.0.0.1:8787
140
+ }
141
+ ```
142
+
143
+ 这会同时转发:
144
+
145
+ - 网页请求 `/`
146
+ - WebSocket `/ws`
147
+
148
+ ### 5.4 电脑启动 agent
149
+
150
+ 第一次:
151
+
152
+ ```bash
153
+ termpilot agent
154
+ ```
155
+
156
+ 然后在终端里输入:
157
+
158
+ 1. relay 域名或 IP
159
+ 2. 端口,直接回车默认 `8787`
160
+
161
+ TermPilot 会自动:
162
+
163
+ - 保存本地配置
164
+ - 后台启动 agent
165
+ - 输出一次性配对码
166
+
167
+ 以后日常:
168
+
169
+ ```bash
170
+ termpilot agent
171
+ ```
172
+
173
+ 如果你只想重新生成一个配对码:
174
+
175
+ ```bash
176
+ termpilot agent --pair
177
+ ```
178
+
179
+ ### 5.5 首次上线后的验收
180
+
181
+ 如果你刚完成一套新部署,建议按下面顺序验收:
182
+
183
+ 1. 服务器执行 `termpilot relay`,确认后台已启动
184
+ 2. 服务器本机执行 `curl http://127.0.0.1:8787/health`
185
+ 3. 手机打开 `https://your-domain.com`
186
+ 4. 电脑执行 `termpilot agent`
187
+ 5. 确认终端里已经打印出配对码
188
+ 6. 手机输入配对码并进入会话列表
189
+ 7. 电脑执行 `termpilot claude code`
190
+ 8. 确认手机端能看到同一个会话的输出
191
+
192
+ ## 6. 目录、数据与状态文件
193
+
194
+ 默认状态目录:
195
+
196
+ ```text
197
+ ~/.termpilot
198
+ ```
199
+
200
+ 常见文件:
201
+
202
+ - `config.json`:agent 本地保存的 relay 配置
203
+ - `agent-runtime.json`:后台 agent 运行时状态
204
+ - `relay-runtime.json`:后台 relay 运行时状态
205
+ - `agent.log`:agent 日志
206
+ - `relay.log`:relay 日志
207
+ - `state.json`:本地会话状态
208
+
209
+ 如果你想切换状态目录,可以设置:
210
+
211
+ ```bash
212
+ TERMPILOT_HOME=/your/path termpilot agent
213
+ TERMPILOT_HOME=/your/path termpilot relay
214
+ ```
215
+
216
+ ## 7. 推荐的日常工作流
217
+
218
+ ### 7.1 服务器
219
+
220
+ 长期保持:
221
+
222
+ ```bash
223
+ termpilot relay
224
+ ```
225
+
226
+ 只有排障时才用:
227
+
228
+ ```bash
229
+ termpilot relay run
230
+ ```
231
+
232
+ ### 7.2 电脑
233
+
234
+ 日常只记住:
235
+
236
+ ```bash
237
+ termpilot agent
238
+ ```
239
+
240
+ 如果你要跑任务,最短路径是:
241
+
242
+ ```bash
243
+ termpilot claude code
244
+ ```
245
+
246
+ 或者:
247
+
248
+ ```bash
249
+ termpilot open code
250
+ ```
251
+
252
+ ### 7.3 手机
253
+
254
+ 长期固定访问:
255
+
256
+ - `https://your-domain.com`
257
+
258
+ 第一次用配对码,之后正常重连不应该要求重新配对。
259
+
260
+ ## 8. 运维动作速查
261
+
262
+ ### 8.1 relay
263
+
264
+ 后台启动:
265
+
266
+ ```bash
267
+ termpilot relay
268
+ ```
269
+
270
+ 查看前台日志:
271
+
272
+ ```bash
273
+ termpilot relay run
274
+ ```
275
+
276
+ 停止后台 relay:
277
+
278
+ ```bash
279
+ termpilot relay stop
280
+ ```
281
+
282
+ ### 8.2 agent
283
+
284
+ 按本机配置启动或查看状态:
285
+
286
+ ```bash
287
+ termpilot agent
288
+ ```
289
+
290
+ 重新生成配对码:
291
+
292
+ ```bash
293
+ termpilot agent --pair
294
+ ```
295
+
296
+ 查看后台状态:
297
+
298
+ ```bash
299
+ termpilot agent status
300
+ ```
301
+
302
+ 停止后台 agent:
303
+
304
+ ```bash
305
+ termpilot agent stop
306
+ ```
307
+
308
+ ### 8.3 会话
309
+
310
+ 直接启动常见任务:
311
+
312
+ ```bash
313
+ termpilot claude code
314
+ termpilot open code
315
+ ```
316
+
317
+ 手动管理会话:
318
+
319
+ ```bash
320
+ termpilot create --name my-task --cwd /path/to/project
321
+ termpilot list
322
+ termpilot attach --sid <sid>
323
+ termpilot kill --sid <sid>
324
+ ```
325
+
326
+ ## 9. 故障排查
327
+
328
+ ### 9.1 手机打开域名,但页面进不去
329
+
330
+ 优先检查:
331
+
332
+ - DNS 是否已经生效
333
+ - `80` / `443` 是否放行
334
+ - 反向代理是否已启动
335
+ - `termpilot relay` 是否真的在跑
336
+
337
+ 服务器检查:
338
+
339
+ ```bash
340
+ termpilot relay
341
+ curl http://127.0.0.1:8787/health
342
+ ```
343
+
344
+ ### 9.2 电脑端执行 `termpilot agent` 后手机还是看不到设备
345
+
346
+ 优先检查:
347
+
348
+ - 电脑是否真的能连到 relay
349
+ - agent 是否已经后台运行
350
+ - 是否第一次配对还没完成
351
+
352
+ 电脑检查:
353
+
354
+ ```bash
355
+ termpilot agent
356
+ termpilot agent status
357
+ ```
358
+
359
+ ### 9.3 手机端看不到某个任务
360
+
361
+ 最常见原因:
362
+
363
+ - 这个任务不是通过 TermPilot 管理的会话启动的
364
+
365
+ 正确做法:
366
+
367
+ ```bash
368
+ termpilot claude code
369
+ ```
370
+
371
+ 或:
372
+
373
+ ```bash
374
+ termpilot create --name my-task --cwd /path/to/project
375
+ termpilot attach --sid <sid>
376
+ ```
377
+
378
+ ### 9.4 想重新绑定手机
379
+
380
+ ```bash
381
+ termpilot agent --pair
382
+ ```
383
+
384
+ 如果要撤销旧设备访问令牌:
385
+
386
+ ```bash
387
+ termpilot grants
388
+ termpilot revoke --token <accessToken>
389
+ ```
390
+
391
+ ### 9.5 relay 或 agent 想看实时日志
392
+
393
+ relay:
394
+
395
+ ```bash
396
+ termpilot relay run
397
+ ```
398
+
399
+ agent:
400
+
401
+ ```bash
402
+ termpilot agent --foreground
403
+ ```
404
+
405
+ ### 9.6 我改了域名或端口,电脑还是连旧地址
406
+
407
+ 先停掉后台 agent,再重新执行一次交互配置:
408
+
409
+ ```bash
410
+ termpilot agent stop
411
+ termpilot agent
412
+ ```
413
+
414
+ 如果你使用了自定义状态目录,也要确认是不是改到了另一个 `TERMPILOT_HOME`。
415
+
416
+ ## 10. 安全建议
417
+
418
+ - 正式环境优先使用域名 + HTTPS/WSS,不要长期裸露 `ws://ip:8787/ws`
419
+ - 不要长期传播手机端访问令牌
420
+ - 换手机或多人共享设备后,及时撤销旧令牌
421
+ - 如果准备长期保存会话元数据,relay 建议接 PostgreSQL
422
+ - 不要把外网入口直接暴露到非预期端口和无 TLS 配置上
423
+
424
+ ## 11. 升级建议
425
+
426
+ 推荐的升级节奏:
427
+
428
+ 1. 先在一台日常不关键的机器上升级验证
429
+ 2. 确认 `termpilot relay` 和 `termpilot agent` 都能正常启动
430
+ 3. 用手机完成一次真实配对和会话查看
431
+ 4. 再升级主力机器
432
+
433
+ 如果升级后出现异常,优先检查:
434
+
435
+ - `~/.termpilot/relay.log`
436
+ - `~/.termpilot/agent.log`
437
+ - `curl http://127.0.0.1:8787/health`
438
+ - `termpilot agent status`
439
+
440
+ ## 12. 建议的文档阅读顺序
441
+
442
+ 1. [README.md](/Users/fengye/workspace/TermPilot/README.md)
443
+ 2. [architecture.md](/Users/fengye/workspace/TermPilot/docs/architecture.md)
444
+ 3. [protocol.md](/Users/fengye/workspace/TermPilot/docs/protocol.md)
445
+ 4. 本文档
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fengye404/termpilot",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@10.31.0",
6
6
  "description": "一个基于 tmux 的终端会话跨端查看与控制原型。",