终端操作: Codex 0.129+ 还要在 TUI 里跑一次 /hooks 命令,审批 Trellis 安装的 UserPromptSubmit hook,否则 hook 不会激活、workflow 指引不会自动注入 # 指定目录 cd /d F:\ # 安装 trellis init -u 2263075977 --codex #激活hook codex /hooks codex桌面版: 1.填充初始任务 /trellis-spec-bootstrap 2.打开计划模式 /grill me 把“需求”拆成 Trellis 父子任务树 3.打开目标模式 1.使用 /trellis:continue 推进每个子任务 2.使用 /trellis:finish-work 归档每个子任务并记录 session journal 3.全部结束后推送 github 还可以iPhone端远程vibe 满足目前的需求 这是最近几天L站看下来的总结 有什么不足或者佬友们有更好的建议 欢迎评论区补充不足 附上魔改metapi进度 纯自用版 1 个帖子 - 1 位参与者 阅读完整话题
佬友们有没有遇到过啊 PhpStorm 2026.1.3 5 个帖子 - 4 位参与者 阅读完整话题
Xiaomi MiMo正式发布并开源MiMo Code,一款运行在终端的探索性AI助手。模型与Agent协同优化,迈向自进化时代。 1.跨会话持久记忆+近乎无限上下文 2.独创Compose编排模式,先设计再编码 3.Dream记忆固化与自进化机制 4.支持语音输入与控制 同时,MiMo Code 内置限时免费的顶级多模态模型–MiMo V2.5,并支持接入DeepSeek等主流模型以及第三方Token Plan,满足不同开发者的需求。 无限上下文?这个真实吗? 3 个帖子 - 3 位参与者 阅读完整话题
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。 Client (Browser/iOS/Android/HarmonyOS) ↕ SignalR Gateway (.NET 10) ↕ SignalR Worker (.NET 10 + PTY) Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。 手搓 SignalR:1091 行 ArkTS 的协议实现 鸿蒙端的第一道坎:SignalR 。 Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。 两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。 我选了后者。 Negotiate 握手 SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken ,后续 WebSocket 连接必须带上这个 token 。 // HttpConnection.ets private async negotiate(accessToken: string): Promise<string> { const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`; const httpClient = http.createHttp(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (accessToken.length > 0) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await httpClient.request(negotiateUrl, { method: http.RequestMethod.POST, header: headers, connectTimeout: 15000, readTimeout: 15000, }); const body = response.result as string; const negotiateResponse = JSON.parse(body) as NegotiateResponse; this.connectionId = negotiateResponse.connectionId ?? ''; return negotiateResponse.connectionToken ?? ''; } 鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp() 和 webSocket.createWebSocket() ,用法跟 Node.js 的差不多,但所有东西都得显式类型声明。 WebSocket 连接 拿到 token 后,拼 URL ,建 WebSocket: // HttpConnection.ets private async connectWebSocket(accessToken: string): Promise<void> { const wsUrl = this.url .replace('https://', 'wss://') .replace('http://', 'ws://'); let fullUrl = wsUrl; const params: string[] = []; if (this.connectionToken.length > 0) { params.push(`id=${encodeURIComponent(this.connectionToken)}`); } if (accessToken.length > 0) { params.push(`access_token=${encodeURIComponent(accessToken)}`); } if (params.length > 0) { fullUrl += '?' + params.join('&'); } this.ws = webSocket.createWebSocket(); const ws = this.ws; const openPromise = new Promise<void>((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', (err: Error) => { if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`)); }); }); ws.on('message', (_err: Error, data: string | ArrayBuffer) => { let text: string; if (typeof data === 'string') { text = data; } else { text = buffer.from(data).toString('utf-8'); } if (this.onreceive !== null) { this.onreceive(text); } }); await ws.connect(fullUrl, { header: connectHeaders }); await openPromise; } Hub 协议层 SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型: Type 1 — InvocationMessage (双向 RPC 调用) Type 2 — StreamItemMessage (流式结果) Type 3 — CompletionMessage ( RPC 响应) Type 6 — Ping (心跳) Type 7 — Close (关闭) 消息之间用 0x1E ( ASCII record separator )分隔。 processIncomingData 是整个消息分发管道的入口: // HubConnection.ets private processIncomingData(data: string): void { // 第一条消息是 handshake response if (this.handshakePromise !== null) { this.protocol.decodeHandshakeResponse(data); const promise = this.handshakePromise; this.handshakePromise = null; promise.resolve(); return; } // 常规消息 const messages = this.protocol.decodeMessages(data, this.logger); for (const message of messages) { this.dispatchMessage(message); } } private dispatchMessage(message: HubMessageBase): void { this.resetServerTimeout(); switch (message.type) { case 1: { // Invocation const invocation = message as InvocationMessage; this.invokeHandler(invocation.target, invocation.arguments); break; } case 2: { // StreamItem const pending = this.streamManager.getInvocation(streamItem.invocationId); if (pending !== undefined) pending.resolve(streamItem.item); break; } case 3: { // Completion const pending = this.streamManager.removeInvocation(completion.invocationId); if (pending !== undefined) { if (completion.error.length > 0) pending.reject(new Error(completion.error)); else pending.resolve(completion.result); } break; } case 6: break; // Ping case 7: this.handleCloseMessage(close); break; } } Keepalive 和重连 心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时: private resetKeepAlive(): void { this.pingTimer = setInterval(() => { const ping = new PingMessage(); const encoded = this.protocol.encodeMessage(ping); this.httpConnection.send(encoded); }, this.keepAliveIntervalInMilliseconds) as number; // 15000ms } private resetServerTimeout(): void { clearTimeout(this.serverTimeoutTimer); this.serverTimeoutTimer = setTimeout(() => { this.httpConnection.stop(new Error('Server timeout')); }, this.serverTimeoutInMilliseconds) as number; // 30000ms } 重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000] ——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开: private scheduleReconnect(): void { if (this.stopRequested) return; const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1); const delay = this.reconnectDelays[delayIndex]; this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number; } ArkTS 的那些坑 写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集: 不能用 as const — 只能用 class X { static readonly A = '...' } 不能写无类型对象字面量 — { key: value } 直接报错,必须声明类型 不能用解构赋值 — const [k, v] of Object.entries(obj) 编译不过 throw 只能抛 Error — catch 到的任意值不能直接 throw 每一条都是我在编译报错后才学到的。 在 ArkWeb 里跑 xterm.js 终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。 HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy ,崩溃不断,换成 WebMessagePort 才稳定下来。 核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听: // XtermWebview.ets private initMessagePort() { this.msgPorts = this.webviewController.createWebMessagePorts(); // Port 1 留在 native 端 this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => { const msg = JSON.parse(result as string) as Record<string, Object>; const type = msg['type'] as string; if (type === 'input') { this.onInput(msg['data'] as string); } else if (type === 'resize') { const cols = msg['cols'] as number; const rows = msg['rows'] as number; this.onResize(cols, rows); } }); // Port 0 发给 HTML 端 this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*'); } 输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm: writeOutput(base64Payload: string) { const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`); } 为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。 整个终端页面的生命周期是一个 9 状态的状态机: Disconnected → Connecting → Replaying → Live → Reconnecting → ... 。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。 手机上怎么按 Ctrl+C 终端有了,但我怎么在手机上发 SIGINT ? 没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。 我的解法是 VirtualKeyBar ——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier :Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。 // VirtualKeyBar.ets — LatchButton 组件 @Component struct LatchButton { label: string = '' @Prop latched: boolean = false onToggle: () => void = () => {} build() { Button(this.label) .backgroundColor(this.latched ? $r('app.color.terminal_secondary_container') : $r('app.color.terminal_surface_container_high')) .onClick(() => { clickHaptic(); this.onToggle(); }) } } Ctrl + 字母的映射藏在 handleVirtualKey 里: // TerminalPage.ets private handleVirtualKey(key: string) { if (key.startsWith('Ctrl+')) { const label = key.substring(5); // a-z → 0x01-0x1A const ch = label.toLowerCase().charCodeAt(0); if (ch >= 97 && ch <= 122) { this.sendInput(String.fromCharCode(ch - 96)); } } // Escape sequences const inputMap: Record<string, string> = { 'ArrowUp': '\x1b[A', 'ArrowDown': '\x1b[B', 'ArrowRight': '\x1b[C', 'ArrowLeft': '\x1b[D', }; } 'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 , String.fromCharCode(3) 就是 \x03 ——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。 CI/CD 十五连跪 6 月 8 号,我开始写 harmony-release.yml 。 然后接下来的 3 天里,我推了这个文件 15 次。 Pipeline 长这样: Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建 → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审 踩坑中最惨的几个: AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。 编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次: - name: Query compile status run: | for i in $(seq 1 20); do SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus') if [ "$SUCCESS_STATUS" = "0" ]; then echo "Compile successful" exit 0 fi sleep 30 done 自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。 每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。 53 天的数字 425 次提交。53 天。1 个人。5 个平台。 其中鸿蒙端: 8645 行 ArkTS 1091 行 手写 SignalR 客户端 9 个 HAR 模块( 1 entry + 5 feature + 3 common ) 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场 接下来要做的:文件传输、端口转发、多 tab 、命令片段。 如果你也觉得手机上应该有个不中断的终端,来看看: github.com/monster-echo/CortexTerminal2 Docker 一键体验: docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。 Client (Browser/iOS/Android/HarmonyOS) ↕ SignalR Gateway (.NET 10) ↕ SignalR Worker (.NET 10 + PTY) Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。 手搓 SignalR:1091 行 ArkTS 的协议实现 鸿蒙端的第一道坎:SignalR 。 Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。 两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。 我选了后者。 Negotiate 握手 SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken ,后续 WebSocket 连接必须带上这个 token 。 // HttpConnection.ets private async negotiate(accessToken: string): Promise<string> { const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`; const httpClient = http.createHttp(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (accessToken.length > 0) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await httpClient.request(negotiateUrl, { method: http.RequestMethod.POST, header: headers, connectTimeout: 15000, readTimeout: 15000, }); const body = response.result as string; const negotiateResponse = JSON.parse(body) as NegotiateResponse; this.connectionId = negotiateResponse.connectionId ?? ''; return negotiateResponse.connectionToken ?? ''; } 鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp() 和 webSocket.createWebSocket() ,用法跟 Node.js 的差不多,但所有东西都得显式类型声明。 WebSocket 连接 拿到 token 后,拼 URL ,建 WebSocket: // HttpConnection.ets private async connectWebSocket(accessToken: string): Promise<void> { const wsUrl = this.url .replace('https://', 'wss://') .replace('http://', 'ws://'); let fullUrl = wsUrl; const params: string[] = []; if (this.connectionToken.length > 0) { params.push(`id=${encodeURIComponent(this.connectionToken)}`); } if (accessToken.length > 0) { params.push(`access_token=${encodeURIComponent(accessToken)}`); } if (params.length > 0) { fullUrl += '?' + params.join('&'); } this.ws = webSocket.createWebSocket(); const ws = this.ws; const openPromise = new Promise<void>((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', (err: Error) => { if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`)); }); }); ws.on('message', (_err: Error, data: string | ArrayBuffer) => { let text: string; if (typeof data === 'string') { text = data; } else { text = buffer.from(data).toString('utf-8'); } if (this.onreceive !== null) { this.onreceive(text); } }); await ws.connect(fullUrl, { header: connectHeaders }); await openPromise; } Hub 协议层 SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型: Type 1 — InvocationMessage (双向 RPC 调用) Type 2 — StreamItemMessage (流式结果) Type 3 — CompletionMessage ( RPC 响应) Type 6 — Ping (心跳) Type 7 — Close (关闭) 消息之间用 0x1E ( ASCII record separator )分隔。 processIncomingData 是整个消息分发管道的入口: // HubConnection.ets private processIncomingData(data: string): void { // 第一条消息是 handshake response if (this.handshakePromise !== null) { this.protocol.decodeHandshakeResponse(data); const promise = this.handshakePromise; this.handshakePromise = null; promise.resolve(); return; } // 常规消息 const messages = this.protocol.decodeMessages(data, this.logger); for (const message of messages) { this.dispatchMessage(message); } } private dispatchMessage(message: HubMessageBase): void { this.resetServerTimeout(); switch (message.type) { case 1: { // Invocation const invocation = message as InvocationMessage; this.invokeHandler(invocation.target, invocation.arguments); break; } case 2: { // StreamItem const pending = this.streamManager.getInvocation(streamItem.invocationId); if (pending !== undefined) pending.resolve(streamItem.item); break; } case 3: { // Completion const pending = this.streamManager.removeInvocation(completion.invocationId); if (pending !== undefined) { if (completion.error.length > 0) pending.reject(new Error(completion.error)); else pending.resolve(completion.result); } break; } case 6: break; // Ping case 7: this.handleCloseMessage(close); break; } } Keepalive 和重连 心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时: private resetKeepAlive(): void { this.pingTimer = setInterval(() => { const ping = new PingMessage(); const encoded = this.protocol.encodeMessage(ping); this.httpConnection.send(encoded); }, this.keepAliveIntervalInMilliseconds) as number; // 15000ms } private resetServerTimeout(): void { clearTimeout(this.serverTimeoutTimer); this.serverTimeoutTimer = setTimeout(() => { this.httpConnection.stop(new Error('Server timeout')); }, this.serverTimeoutInMilliseconds) as number; // 30000ms } 重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000] ——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开: private scheduleReconnect(): void { if (this.stopRequested) return; const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1); const delay = this.reconnectDelays[delayIndex]; this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number; } ArkTS 的那些坑 写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集: 不能用 as const — 只能用 class X { static readonly A = '...' } 不能写无类型对象字面量 — { key: value } 直接报错,必须声明类型 不能用解构赋值 — const [k, v] of Object.entries(obj) 编译不过 throw 只能抛 Error — catch 到的任意值不能直接 throw 每一条都是我在编译报错后才学到的。 在 ArkWeb 里跑 xterm.js 终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。 HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy ,崩溃不断,换成 WebMessagePort 才稳定下来。 核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听: // XtermWebview.ets private initMessagePort() { this.msgPorts = this.webviewController.createWebMessagePorts(); // Port 1 留在 native 端 this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => { const msg = JSON.parse(result as string) as Record<string, Object>; const type = msg['type'] as string; if (type === 'input') { this.onInput(msg['data'] as string); } else if (type === 'resize') { const cols = msg['cols'] as number; const rows = msg['rows'] as number; this.onResize(cols, rows); } }); // Port 0 发给 HTML 端 this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*'); } 输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm: writeOutput(base64Payload: string) { const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`); } 为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。 整个终端页面的生命周期是一个 9 状态的状态机: Disconnected → Connecting → Replaying → Live → Reconnecting → ... 。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。 手机上怎么按 Ctrl+C 终端有了,但我怎么在手机上发 SIGINT ? 没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。 我的解法是 VirtualKeyBar ——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier :Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。 // VirtualKeyBar.ets — LatchButton 组件 @Component struct LatchButton { label: string = '' @Prop latched: boolean = false onToggle: () => void = () => {} build() { Button(this.label) .backgroundColor(this.latched ? $r('app.color.terminal_secondary_container') : $r('app.color.terminal_surface_container_high')) .onClick(() => { clickHaptic(); this.onToggle(); }) } } Ctrl + 字母的映射藏在 handleVirtualKey 里: // TerminalPage.ets private handleVirtualKey(key: string) { if (key.startsWith('Ctrl+')) { const label = key.substring(5); // a-z → 0x01-0x1A const ch = label.toLowerCase().charCodeAt(0); if (ch >= 97 && ch <= 122) { this.sendInput(String.fromCharCode(ch - 96)); } } // Escape sequences const inputMap: Record<string, string> = { 'ArrowUp': '\x1b[A', 'ArrowDown': '\x1b[B', 'ArrowRight': '\x1b[C', 'ArrowLeft': '\x1b[D', }; } 'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 , String.fromCharCode(3) 就是 \x03 ——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。 CI/CD 十五连跪 6 月 8 号,我开始写 harmony-release.yml 。 然后接下来的 3 天里,我推了这个文件 15 次。 Pipeline 长这样: Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建 → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审 踩坑中最惨的几个: AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。 编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次: - name: Query compile status run: | for i in $(seq 1 20); do SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus') if [ "$SUCCESS_STATUS" = "0" ]; then echo "Compile successful" exit 0 fi sleep 30 done 自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。 每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。 53 天的数字 425 次提交。53 天。1 个人。5 个平台。 其中鸿蒙端: 8645 行 ArkTS 1091 行 手写 SignalR 客户端 9 个 HAR 模块( 1 entry + 5 feature + 3 common ) 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场 接下来要做的:文件传输、端口转发、多 tab 、命令片段。 如果你也觉得手机上应该有个不中断的终端,来看看: github.com/monster-echo/CortexTerminal2 Docker 一键体验: docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
支持多 tab 标签页,支持多分组(我会在本地开很多终端,干不同的活,所以要分组区分下,最好自定义分组名) 要流畅(这个应该是最基础的了吧),不能比原生差太多。 试了下 ghostty ,流畅是流畅,但是也不支持分组。 是不是目前市面上没有支持这个功能的终端工具?大多能做到 tab 切换级别
那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。 Client (Browser/iOS/Android/HarmonyOS) ↕ SignalR Gateway (.NET 10) ↕ SignalR Worker (.NET 10 + PTY) Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。 手搓 SignalR:1091 行 ArkTS 的协议实现 鸿蒙端的第一道坎:SignalR 。 Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。 两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。 我选了后者。 Negotiate 握手 SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken ,后续 WebSocket 连接必须带上这个 token 。 // HttpConnection.ets private async negotiate(accessToken: string): Promise<string> { const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`; const httpClient = http.createHttp(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (accessToken.length > 0) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await httpClient.request(negotiateUrl, { method: http.RequestMethod.POST, header: headers, connectTimeout: 15000, readTimeout: 15000, }); const body = response.result as string; const negotiateResponse = JSON.parse(body) as NegotiateResponse; this.connectionId = negotiateResponse.connectionId ?? ''; return negotiateResponse.connectionToken ?? ''; } 鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp() 和 webSocket.createWebSocket() ,用法跟 Node.js 的差不多,但所有东西都得显式类型声明。 WebSocket 连接 拿到 token 后,拼 URL ,建 WebSocket: // HttpConnection.ets private async connectWebSocket(accessToken: string): Promise<void> { const wsUrl = this.url .replace('https://', 'wss://') .replace('http://', 'ws://'); let fullUrl = wsUrl; const params: string[] = []; if (this.connectionToken.length > 0) { params.push(`id=${encodeURIComponent(this.connectionToken)}`); } if (accessToken.length > 0) { params.push(`access_token=${encodeURIComponent(accessToken)}`); } if (params.length > 0) { fullUrl += '?' + params.join('&'); } this.ws = webSocket.createWebSocket(); const ws = this.ws; const openPromise = new Promise<void>((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', (err: Error) => { if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`)); }); }); ws.on('message', (_err: Error, data: string | ArrayBuffer) => { let text: string; if (typeof data === 'string') { text = data; } else { text = buffer.from(data).toString('utf-8'); } if (this.onreceive !== null) { this.onreceive(text); } }); await ws.connect(fullUrl, { header: connectHeaders }); await openPromise; } Hub 协议层 SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型: Type 1 — InvocationMessage (双向 RPC 调用) Type 2 — StreamItemMessage (流式结果) Type 3 — CompletionMessage ( RPC 响应) Type 6 — Ping (心跳) Type 7 — Close (关闭) 消息之间用 0x1E ( ASCII record separator )分隔。 processIncomingData 是整个消息分发管道的入口: // HubConnection.ets private processIncomingData(data: string): void { // 第一条消息是 handshake response if (this.handshakePromise !== null) { this.protocol.decodeHandshakeResponse(data); const promise = this.handshakePromise; this.handshakePromise = null; promise.resolve(); return; } // 常规消息 const messages = this.protocol.decodeMessages(data, this.logger); for (const message of messages) { this.dispatchMessage(message); } } private dispatchMessage(message: HubMessageBase): void { this.resetServerTimeout(); switch (message.type) { case 1: { // Invocation const invocation = message as InvocationMessage; this.invokeHandler(invocation.target, invocation.arguments); break; } case 2: { // StreamItem const pending = this.streamManager.getInvocation(streamItem.invocationId); if (pending !== undefined) pending.resolve(streamItem.item); break; } case 3: { // Completion const pending = this.streamManager.removeInvocation(completion.invocationId); if (pending !== undefined) { if (completion.error.length > 0) pending.reject(new Error(completion.error)); else pending.resolve(completion.result); } break; } case 6: break; // Ping case 7: this.handleCloseMessage(close); break; } } Keepalive 和重连 心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时: private resetKeepAlive(): void { this.pingTimer = setInterval(() => { const ping = new PingMessage(); const encoded = this.protocol.encodeMessage(ping); this.httpConnection.send(encoded); }, this.keepAliveIntervalInMilliseconds) as number; // 15000ms } private resetServerTimeout(): void { clearTimeout(this.serverTimeoutTimer); this.serverTimeoutTimer = setTimeout(() => { this.httpConnection.stop(new Error('Server timeout')); }, this.serverTimeoutInMilliseconds) as number; // 30000ms } 重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000] ——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开: private scheduleReconnect(): void { if (this.stopRequested) return; const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1); const delay = this.reconnectDelays[delayIndex]; this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number; } ArkTS 的那些坑 写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集: 不能用 as const — 只能用 class X { static readonly A = '...' } 不能写无类型对象字面量 — { key: value } 直接报错,必须声明类型 不能用解构赋值 — const [k, v] of Object.entries(obj) 编译不过 throw 只能抛 Error — catch 到的任意值不能直接 throw 每一条都是我在编译报错后才学到的。 在 ArkWeb 里跑 xterm.js 终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。 HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy ,崩溃不断,换成 WebMessagePort 才稳定下来。 核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听: // XtermWebview.ets private initMessagePort() { this.msgPorts = this.webviewController.createWebMessagePorts(); // Port 1 留在 native 端 this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => { const msg = JSON.parse(result as string) as Record<string, Object>; const type = msg['type'] as string; if (type === 'input') { this.onInput(msg['data'] as string); } else if (type === 'resize') { const cols = msg['cols'] as number; const rows = msg['rows'] as number; this.onResize(cols, rows); } }); // Port 0 发给 HTML 端 this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*'); } 输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm: writeOutput(base64Payload: string) { const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`); } 为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。 整个终端页面的生命周期是一个 9 状态的状态机: Disconnected → Connecting → Replaying → Live → Reconnecting → ... 。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。 手机上怎么按 Ctrl+C 终端有了,但我怎么在手机上发 SIGINT ? 没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。 我的解法是 VirtualKeyBar ——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier :Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。 // VirtualKeyBar.ets — LatchButton 组件 @Component struct LatchButton { label: string = '' @Prop latched: boolean = false onToggle: () => void = () => {} build() { Button(this.label) .backgroundColor(this.latched ? $r('app.color.terminal_secondary_container') : $r('app.color.terminal_surface_container_high')) .onClick(() => { clickHaptic(); this.onToggle(); }) } } Ctrl + 字母的映射藏在 handleVirtualKey 里: // TerminalPage.ets private handleVirtualKey(key: string) { if (key.startsWith('Ctrl+')) { const label = key.substring(5); // a-z → 0x01-0x1A const ch = label.toLowerCase().charCodeAt(0); if (ch >= 97 && ch <= 122) { this.sendInput(String.fromCharCode(ch - 96)); } } // Escape sequences const inputMap: Record<string, string> = { 'ArrowUp': '\x1b[A', 'ArrowDown': '\x1b[B', 'ArrowRight': '\x1b[C', 'ArrowLeft': '\x1b[D', }; } 'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 , String.fromCharCode(3) 就是 \x03 ——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。 CI/CD 十五连跪 6 月 8 号,我开始写 harmony-release.yml 。 然后接下来的 3 天里,我推了这个文件 15 次。 Pipeline 长这样: Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建 → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审 踩坑中最惨的几个: AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。 编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次: - name: Query compile status run: | for i in $(seq 1 20); do SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus') if [ "$SUCCESS_STATUS" = "0" ]; then echo "Compile successful" exit 0 fi sleep 30 done 自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。 每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。 53 天的数字 425 次提交。53 天。1 个人。5 个平台。 其中鸿蒙端: 8645 行 ArkTS 1091 行 手写 SignalR 客户端 9 个 HAR 模块( 1 entry + 5 feature + 3 common ) 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场 接下来要做的:文件传输、端口转发、多 tab 、命令片段。 如果你也觉得手机上应该有个不中断的终端,来看看: github.com/monster-echo/CortexTerminal2 Docker 一键体验: docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest
那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。 Client (Browser/iOS/Android/HarmonyOS) ↕ SignalR Gateway (.NET 10) ↕ SignalR Worker (.NET 10 + PTY) Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。 手搓 SignalR:1091 行 ArkTS 的协议实现 鸿蒙端的第一道坎:SignalR 。 Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。 两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。 我选了后者。 Negotiate 握手 SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken ,后续 WebSocket 连接必须带上这个 token 。 // HttpConnection.ets private async negotiate(accessToken: string): Promise<string> { const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`; const httpClient = http.createHttp(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (accessToken.length > 0) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await httpClient.request(negotiateUrl, { method: http.RequestMethod.POST, header: headers, connectTimeout: 15000, readTimeout: 15000, }); const body = response.result as string; const negotiateResponse = JSON.parse(body) as NegotiateResponse; this.connectionId = negotiateResponse.connectionId ?? ''; return negotiateResponse.connectionToken ?? ''; } 鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp() 和 webSocket.createWebSocket() ,用法跟 Node.js 的差不多,但所有东西都得显式类型声明。 WebSocket 连接 拿到 token 后,拼 URL ,建 WebSocket: // HttpConnection.ets private async connectWebSocket(accessToken: string): Promise<void> { const wsUrl = this.url .replace('https://', 'wss://') .replace('http://', 'ws://'); let fullUrl = wsUrl; const params: string[] = []; if (this.connectionToken.length > 0) { params.push(`id=${encodeURIComponent(this.connectionToken)}`); } if (accessToken.length > 0) { params.push(`access_token=${encodeURIComponent(accessToken)}`); } if (params.length > 0) { fullUrl += '?' + params.join('&'); } this.ws = webSocket.createWebSocket(); const ws = this.ws; const openPromise = new Promise<void>((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', (err: Error) => { if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`)); }); }); ws.on('message', (_err: Error, data: string | ArrayBuffer) => { let text: string; if (typeof data === 'string') { text = data; } else { text = buffer.from(data).toString('utf-8'); } if (this.onreceive !== null) { this.onreceive(text); } }); await ws.connect(fullUrl, { header: connectHeaders }); await openPromise; } Hub 协议层 SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型: Type 1 — InvocationMessage (双向 RPC 调用) Type 2 — StreamItemMessage (流式结果) Type 3 — CompletionMessage ( RPC 响应) Type 6 — Ping (心跳) Type 7 — Close (关闭) 消息之间用 0x1E ( ASCII record separator )分隔。 processIncomingData 是整个消息分发管道的入口: // HubConnection.ets private processIncomingData(data: string): void { // 第一条消息是 handshake response if (this.handshakePromise !== null) { this.protocol.decodeHandshakeResponse(data); const promise = this.handshakePromise; this.handshakePromise = null; promise.resolve(); return; } // 常规消息 const messages = this.protocol.decodeMessages(data, this.logger); for (const message of messages) { this.dispatchMessage(message); } } private dispatchMessage(message: HubMessageBase): void { this.resetServerTimeout(); switch (message.type) { case 1: { // Invocation const invocation = message as InvocationMessage; this.invokeHandler(invocation.target, invocation.arguments); break; } case 2: { // StreamItem const pending = this.streamManager.getInvocation(streamItem.invocationId); if (pending !== undefined) pending.resolve(streamItem.item); break; } case 3: { // Completion const pending = this.streamManager.removeInvocation(completion.invocationId); if (pending !== undefined) { if (completion.error.length > 0) pending.reject(new Error(completion.error)); else pending.resolve(completion.result); } break; } case 6: break; // Ping case 7: this.handleCloseMessage(close); break; } } Keepalive 和重连 心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时: private resetKeepAlive(): void { this.pingTimer = setInterval(() => { const ping = new PingMessage(); const encoded = this.protocol.encodeMessage(ping); this.httpConnection.send(encoded); }, this.keepAliveIntervalInMilliseconds) as number; // 15000ms } private resetServerTimeout(): void { clearTimeout(this.serverTimeoutTimer); this.serverTimeoutTimer = setTimeout(() => { this.httpConnection.stop(new Error('Server timeout')); }, this.serverTimeoutInMilliseconds) as number; // 30000ms } 重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000] ——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开: private scheduleReconnect(): void { if (this.stopRequested) return; const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1); const delay = this.reconnectDelays[delayIndex]; this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number; } ArkTS 的那些坑 写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集: 不能用 as const — 只能用 class X { static readonly A = '...' } 不能写无类型对象字面量 — { key: value } 直接报错,必须声明类型 不能用解构赋值 — const [k, v] of Object.entries(obj) 编译不过 throw 只能抛 Error — catch 到的任意值不能直接 throw 每一条都是我在编译报错后才学到的。 在 ArkWeb 里跑 xterm.js 终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。 HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy ,崩溃不断,换成 WebMessagePort 才稳定下来。 核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听: // XtermWebview.ets private initMessagePort() { this.msgPorts = this.webviewController.createWebMessagePorts(); // Port 1 留在 native 端 this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => { const msg = JSON.parse(result as string) as Record<string, Object>; const type = msg['type'] as string; if (type === 'input') { this.onInput(msg['data'] as string); } else if (type === 'resize') { const cols = msg['cols'] as number; const rows = msg['rows'] as number; this.onResize(cols, rows); } }); // Port 0 发给 HTML 端 this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*'); } 输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm: writeOutput(base64Payload: string) { const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`); } 为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。 整个终端页面的生命周期是一个 9 状态的状态机: Disconnected → Connecting → Replaying → Live → Reconnecting → ... 。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。 手机上怎么按 Ctrl+C 终端有了,但我怎么在手机上发 SIGINT ? 没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。 我的解法是 VirtualKeyBar ——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier :Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。 // VirtualKeyBar.ets — LatchButton 组件 @Component struct LatchButton { label: string = '' @Prop latched: boolean = false onToggle: () => void = () => {} build() { Button(this.label) .backgroundColor(this.latched ? $r('app.color.terminal_secondary_container') : $r('app.color.terminal_surface_container_high')) .onClick(() => { clickHaptic(); this.onToggle(); }) } } Ctrl + 字母的映射藏在 handleVirtualKey 里: // TerminalPage.ets private handleVirtualKey(key: string) { if (key.startsWith('Ctrl+')) { const label = key.substring(5); // a-z → 0x01-0x1A const ch = label.toLowerCase().charCodeAt(0); if (ch >= 97 && ch <= 122) { this.sendInput(String.fromCharCode(ch - 96)); } } // Escape sequences const inputMap: Record<string, string> = { 'ArrowUp': '\x1b[A', 'ArrowDown': '\x1b[B', 'ArrowRight': '\x1b[C', 'ArrowLeft': '\x1b[D', }; } 'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 , String.fromCharCode(3) 就是 \x03 ——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。 CI/CD 十五连跪 6 月 8 号,我开始写 harmony-release.yml 。 然后接下来的 3 天里,我推了这个文件 15 次。 Pipeline 长这样: Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建 → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审 踩坑中最惨的几个: AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。 编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次: - name: Query compile status run: | for i in $(seq 1 20); do SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus') if [ "$SUCCESS_STATUS" = "0" ]; then echo "Compile successful" exit 0 fi sleep 30 done 自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。 每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。 53 天的数字 425 次提交。53 天。1 个人。5 个平台。 其中鸿蒙端: 8645 行 ArkTS 1091 行 手写 SignalR 客户端 9 个 HAR 模块( 1 entry + 5 feature + 3 common ) 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场 接下来要做的:文件传输、端口转发、多 tab 、命令片段。 如果你也觉得手机上应该有个不中断的终端,来看看: github.com/monster-echo/CortexTerminal2 Docker 一键体验: docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest
小米正式发布并开源 MiMoCode V0.1.0 —— 一款运行在终端里的探索性 AI 编程助手。 MiMo Code 始于编程,不止于编程 。它不只是一个好用的 AI Coding 工具,更是一位住在你电脑里、越用越懂你的 AI 队友。 它内置限时免费的顶级多模态模型 **MiMo-V2.5,**性能比肩 Claude Sonnet 4.6;同时支持接入 DeepSeek、Kimi 和 GLM 等主流模型,以及第三方 Token Plan,满足不同开发者的需求。 MiMo Code 基于开源项目 OpenCode 二次开发, 发布并开源,采用 MIT协议 。 1 个帖子 - 1 位参与者 阅读完整话题
那天晚上 11 点,我在火车上 SSH 到服务器查日志。手机浏览器切了个微信回来,tab 被 kill 了,session 断了,查了一半的日志全没了。 我翻了翻手机上所有终端 app——Termius 、Blink Shell 、ServerCat——它们都有同一个问题:你不能真的"保持连接"。系统杀后台、网络切换、锁屏省电,随便哪个都能把你的 SSH 掐断。 那能不能反过来?让 shell 在远程服务器上一直跑,手机只是个"显示器"——断了就断了,重连回来输出还在。 这就是 Corterm (云枢终端)的出发点: session 不是连接,是状态。 先把架子搭起来 思路很直接: Worker — 装在远程机器上的轻量 agent ,管 PTY 生命周期。你断了连,shell 照跑。 Gateway — 中间层,管认证、路由、session 协调。Worker 和 Client 之间不直接通信。 Client — 纯渲染层。断了重连时,Gateway 把 Worker 上的 scrollback buffer 吐给你,无缝衔接。 Client (Browser/iOS/Android/HarmonyOS) ↕ SignalR Gateway (.NET 10) ↕ SignalR Worker (.NET 10 + PTY) Gateway 和 Worker 用 .NET 10 + SignalR ,Client 端浏览器用 React + xterm.js ,iOS/Android 用 MAUI 。浏览器、手机 App 都跑通了,接下来是鸿蒙。 手搓 SignalR:1091 行 ArkTS 的协议实现 鸿蒙端的第一道坎:SignalR 。 Corterm 的 Gateway 是 .NET 写的,实时通信用的 SignalR 。iOS/Android 那边有官方 SDK ,浏览器更不用说。但鸿蒙……我翻了半天文档,没有。连第三方实现都没有。 两条路:要么在 Gateway 加一层 WebSocket 中间层,要么直接在 ArkTS 里实现 SignalR 协议。前者意味着改服务端,所有客户端都得测。后者意味着我要在一个 TypeScript 的严格子集里,手写一个协议栈。 我选了后者。 Negotiate 握手 SignalR 连接的第一步不是 WebSocket ,而是一个 HTTP POST negotiate 请求。服务端返回一个 connectionToken ,后续 WebSocket 连接必须带上这个 token 。 // HttpConnection.ets private async negotiate(accessToken: string): Promise<string> { const negotiateUrl = `${this.url}/negotiate?negotiateVersion=1`; const httpClient = http.createHttp(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; if (accessToken.length > 0) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await httpClient.request(negotiateUrl, { method: http.RequestMethod.POST, header: headers, connectTimeout: 15000, readTimeout: 15000, }); const body = response.result as string; const negotiateResponse = JSON.parse(body) as NegotiateResponse; this.connectionId = negotiateResponse.connectionId ?? ''; return negotiateResponse.connectionToken ?? ''; } 鸿蒙的网络 API 是 @kit.NetworkKit 里的 http.createHttp() 和 webSocket.createWebSocket() ,用法跟 Node.js 的差不多,但所有东西都得显式类型声明。 WebSocket 连接 拿到 token 后,拼 URL ,建 WebSocket: // HttpConnection.ets private async connectWebSocket(accessToken: string): Promise<void> { const wsUrl = this.url .replace('https://', 'wss://') .replace('http://', 'ws://'); let fullUrl = wsUrl; const params: string[] = []; if (this.connectionToken.length > 0) { params.push(`id=${encodeURIComponent(this.connectionToken)}`); } if (accessToken.length > 0) { params.push(`access_token=${encodeURIComponent(accessToken)}`); } if (params.length > 0) { fullUrl += '?' + params.join('&'); } this.ws = webSocket.createWebSocket(); const ws = this.ws; const openPromise = new Promise<void>((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', (err: Error) => { if (!this.stopRequested) reject(new Error(`WebSocket error: ${err.message}`)); }); }); ws.on('message', (_err: Error, data: string | ArrayBuffer) => { let text: string; if (typeof data === 'string') { text = data; } else { text = buffer.from(data).toString('utf-8'); } if (this.onreceive !== null) { this.onreceive(text); } }); await ws.connect(fullUrl, { header: connectHeaders }); await openPromise; } Hub 协议层 SignalR 不是裸 WebSocket 。它有自己的消息格式——我打开 C# 源码看了下,其实就 5 种消息类型: Type 1 — InvocationMessage (双向 RPC 调用) Type 2 — StreamItemMessage (流式结果) Type 3 — CompletionMessage ( RPC 响应) Type 6 — Ping (心跳) Type 7 — Close (关闭) 消息之间用 0x1E ( ASCII record separator )分隔。 processIncomingData 是整个消息分发管道的入口: // HubConnection.ets private processIncomingData(data: string): void { // 第一条消息是 handshake response if (this.handshakePromise !== null) { this.protocol.decodeHandshakeResponse(data); const promise = this.handshakePromise; this.handshakePromise = null; promise.resolve(); return; } // 常规消息 const messages = this.protocol.decodeMessages(data, this.logger); for (const message of messages) { this.dispatchMessage(message); } } private dispatchMessage(message: HubMessageBase): void { this.resetServerTimeout(); switch (message.type) { case 1: { // Invocation const invocation = message as InvocationMessage; this.invokeHandler(invocation.target, invocation.arguments); break; } case 2: { // StreamItem const pending = this.streamManager.getInvocation(streamItem.invocationId); if (pending !== undefined) pending.resolve(streamItem.item); break; } case 3: { // Completion const pending = this.streamManager.removeInvocation(completion.invocationId); if (pending !== undefined) { if (completion.error.length > 0) pending.reject(new Error(completion.error)); else pending.resolve(completion.result); } break; } case 6: break; // Ping case 7: this.handleCloseMessage(close); break; } } Keepalive 和重连 心跳每 15 秒发一次 Ping ,服务端 30 秒没消息就判定超时: private resetKeepAlive(): void { this.pingTimer = setInterval(() => { const ping = new PingMessage(); const encoded = this.protocol.encodeMessage(ping); this.httpConnection.send(encoded); }, this.keepAliveIntervalInMilliseconds) as number; // 15000ms } private resetServerTimeout(): void { clearTimeout(this.serverTimeoutTimer); this.serverTimeoutTimer = setTimeout(() => { this.httpConnection.stop(new Error('Server timeout')); }, this.serverTimeoutInMilliseconds) as number; // 30000ms } 重连策略是 SignalR 的经典配置 [0, 2000, 5000, 10000, 30000] ——先立即重试,然后 2 秒、5 秒、10 秒、30 秒。但官方 SDK 试完这 5 次就放弃了。我的实现改成了循环重试,延迟数组里的最后一个值( 30 秒)会一直用下去,最多 15 次之后才真正断开: private scheduleReconnect(): void { if (this.stopRequested) return; const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1); const delay = this.reconnectDelays[delayIndex]; this.reconnectTimer = setTimeout(() => this.attemptReconnect(), delay) as number; } ArkTS 的那些坑 写 SignalR 客户端最痛的不是协议本身,而是 ArkTS 的限制。它是 TypeScript 的严格子集: 不能用 as const — 只能用 class X { static readonly A = '...' } 不能写无类型对象字面量 — { key: value } 直接报错,必须声明类型 不能用解构赋值 — const [k, v] of Object.entries(obj) 编译不过 throw 只能抛 Error — catch 到的任意值不能直接 throw 每一条都是我在编译报错后才学到的。 在 ArkWeb 里跑 xterm.js 终端渲染的答案很明确:xterm.js 。问题是它跑在浏览器里,而我要在 HarmonyOS 的原生 app 里用它。 HarmonyOS 提供了 ArkWeb ( WebView 组件),有 WebMessagePort 做双向通信。我先试了 javaScriptProxy ,崩溃不断,换成 WebMessagePort 才稳定下来。 核心逻辑:创建一对 MessagePort ,Port 0 发给 HTML 端,Port 1 留在 native 端监听: // XtermWebview.ets private initMessagePort() { this.msgPorts = this.webviewController.createWebMessagePorts(); // Port 1 留在 native 端 this.msgPorts[1].onMessageEvent((result: webview.WebMessage) => { const msg = JSON.parse(result as string) as Record<string, Object>; const type = msg['type'] as string; if (type === 'input') { this.onInput(msg['data'] as string); } else if (type === 'resize') { const cols = msg['cols'] as number; const rows = msg['rows'] as number; this.onResize(cols, rows); } }); // Port 0 发给 HTML 端 this.webviewController.postMessage('__init_port__', [this.msgPorts[0]], '*'); } 输出方向反过来:native 拿到 Worker 的输出,base64 编码后调 runJavaScript 写入 xterm: writeOutput(base64Payload: string) { const escaped = base64Payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); this.webviewController.runJavaScript(`writeBase64Output("${escaped}")`); } 为什么用 base64 ?因为终端输出包含二进制数据( ANSI 转义序列、控制字符),直接当 JSON 字符串传会炸。 整个终端页面的生命周期是一个 9 状态的状态机: Disconnected → Connecting → Replaying → Live → Reconnecting → ... 。重连时 Gateway 先 replay scrollback buffer ,然后切到 Live 模式,用户感觉不到断过。 手机上怎么按 Ctrl+C 终端有了,但我怎么在手机上发 SIGINT ? 没有键盘的设备用终端,这是所有移动端终端 app 的噩梦。 我的解法是 VirtualKeyBar ——一个水平可滚动的虚拟按键条。关键是 Sticky Modifier :Ctrl 和 Alt 是 latch 按键,按一下变亮(激活),再按下一个字符键时才发送组合键。 // VirtualKeyBar.ets — LatchButton 组件 @Component struct LatchButton { label: string = '' @Prop latched: boolean = false onToggle: () => void = () => {} build() { Button(this.label) .backgroundColor(this.latched ? $r('app.color.terminal_secondary_container') : $r('app.color.terminal_surface_container_high')) .onClick(() => { clickHaptic(); this.onToggle(); }) } } Ctrl + 字母的映射藏在 handleVirtualKey 里: // TerminalPage.ets private handleVirtualKey(key: string) { if (key.startsWith('Ctrl+')) { const label = key.substring(5); // a-z → 0x01-0x1A const ch = label.toLowerCase().charCodeAt(0); if (ch >= 97 && ch <= 122) { this.sendInput(String.fromCharCode(ch - 96)); } } // Escape sequences const inputMap: Record<string, string> = { 'ArrowUp': '\x1b[A', 'ArrowDown': '\x1b[B', 'ArrowRight': '\x1b[C', 'ArrowLeft': '\x1b[D', }; } 'c'.charCodeAt(0) 是 99 ( 0x63 ),减 96 得 3 , String.fromCharCode(3) 就是 \x03 ——SIGINT 。一行数学运算解决了所有 Ctrl+字母的映射。 CI/CD 十五连跪 6 月 8 号,我开始写 harmony-release.yml 。 然后接下来的 3 天里,我推了这个文件 15 次。 Pipeline 长这样: Tag push (harmony-v*) → 版本提取 → 签名准备 → hvigor 构建 → AGConnect 认证 → OBS 上传 → 编译轮询 → 提审 踩坑中最惨的几个: AGConnect API 文档是解谜游戏。 /upload-url/for-obs 端点文档只写了入参,没告诉你返回的 header 要原样传给 OBS 的 PUT 请求。我是抓包才搞明白的。 编译状态要轮询。 华为的服务端编译一个 .app 文件要 60 秒以上,API 没有回调,只能 30 秒一次轮询,最多 20 次: - name: Query compile status run: | for i in $(seq 1 20); do SUCCESS_STATUS=$(curl -s ... | jq -r '.pkgStateList[0].successStatus') if [ "$SUCCESS_STATUS" = "0" ]; then echo "Compile successful" exit 0 fi sleep 30 done 自托管 runner 的脏文件。 有一次构建失败,查了半天发现是 /tmp 下残留了上次的 .app 文件,签名步骤拿错了文件。于是加了一行 rm -rf 在 pipeline 开头。 每次看到 GitHub Actions 红叉,我都觉得自己在跟华为的文档玩解谜游戏。 53 天的数字 425 次提交。53 天。1 个人。5 个平台。 其中鸿蒙端: 8645 行 ArkTS 1091 行 手写 SignalR 客户端 9 个 HAR 模块( 1 entry + 5 feature + 3 common ) 5 月 8 日 第一个鸿蒙 commit → 6 月 11 日 上架华为应用市场 接下来要做的:文件传输、端口转发、多 tab 、命令片段。 如果你也觉得手机上应该有个不中断的终端,来看看: github.com/monster-echo/CortexTerminal2 Docker 一键体验: docker run -d -p 5000:5000 ghcr.io/monster-echo/cortex-terminal:latest
MiMoCode 是一个终端原生的 AI 编程助手。它能读写代码、执行命令、管理 Git,通过持久化记忆系统,在多次会话间保持对你项目的深度理解,并自我进化。 内置 MiMo Auto 限时免费通道——零配置即可开始使用。也支持接入各家主流 LLM 厂商 API。 github.com GitHub - XiaomiMiMo/MiMo-Code 通过在 GitHub 上创建帐户来为 XiaomiMiMo/MiMo-Code 开发做出贡献。 35 个帖子 - 19 位参与者 阅读完整话题
有没有佬知道怎么解决啊? 1 个帖子 - 1 位参与者 阅读完整话题
大概思路就是,一共十几个不同的分工(有市场分析,写大纲,写草稿,写正文,润色,去AI化,等等),这里有一个比较重要的是,每一章写好后有一个agent去进行打分,从各种维度对这一章节打分,低于多少分就会打回重写或者修改。我主要跟一个主控对话,然后让他去调用其他的agent 3 个帖子 - 3 位参与者 阅读完整话题