前因是这样: 5月30日,Agents 抽风了一直提交,触发了将近 50 次部署,直接把 Vercel 的 Hobby 额度全刷没了。 但是 Vercel 一直只提醒,不处理,直到今天… 然后,晚上就开始排查原因,和 Claude 聊了一轮定位到具体原因后,问补救办法。 结果是异常顺利,处理的是 AI,我就直接把 Claude 写的内容发过去,就直接解封了!! 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
那天晚上 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
那天晚上 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
以前ai生成完代码,我还会打开idea看一遍,提交前也会习惯性地在idea里跑一下项目。但后来工作量越来越大,ai也越来越强,现在基本上一天就是codex一把梭,idea都很少打开了。 目前还离不开idea的地方主要有两个:一个是git分支管理,之前用idea的分支切换、合并这些操作习惯了,偶尔分支乱了还是会点开idea处理一下;另一个是项目启动,有时候担心ai改的代码有问题,还是会用idea跑起来看一眼。 所以想问问大家,现在的工作工具都是怎么搭配的?我其实有点想完全抛弃idea,但git管理和项目启动这两块还没找到特别顺手的替代方案,想看看大家有没有什么好方法可以借鉴。 14 个帖子 - 10 位参与者 阅读完整话题
1.开户之后可以修改手机号吗?看网上说国内的手机号收不到短信,已经提交了,正在审核中,开户成功后能改手机号吗? 2.可以等审核通过之后再签署w-8表格吗? 还有什么隐藏的坑各位大佬也可以提示一下 4 个帖子 - 2 位参与者 阅读完整话题
Copilot 全局固定 Commit Message 格式 mac下编辑这个文件: ~/.config/github-copilot/intellij/global-git-commit-instructions.md 写入规则,例如: 使用 Conventional Commits 格式生成提交信息。 规则: - 类型用英文:feat、fix、docs、style、refactor、test、chore - 冒号后用中文描述 - 尽量单行 - 不超过 72 个字符 示例: - feat: 新增用户列表页面 - fix: 修复登录后未跳转首页的问题 保存后重启 IDEA / WebStorm。 GitHub Docs Adding repository custom instructions for GitHub Copilot in your IDE - GitHub... Create repository custom instructions files that give Copilot additional context on how to understand your project and how to build, test and validate its changes. 1 个帖子 - 1 位参与者 阅读完整话题
因为文档不想随着代码提交,所以代码和文档在不同的文件夹。在开发的时候,当前工作目录就是代码的文件夹,请问要怎么引用文档呢。 做过的尝试如下: 在claude使用add-dir添加额外的文档目录,但是没办法使用@命令去索引文件。 将文档目录放在项目目录里面,因为不想提交文档目录,在gitignore忽略文档目录,也会导致@命令无法索引 6 个帖子 - 6 位参与者 阅读完整话题
前几天的时候用的是常用的苹果id的账号注册出现这个情况 那天也重新注册了一个苹果id 今天又去注册还是这种情况 今天注册的时候我严格按照身份证上的信息填写的 提交地址信息后就被拦截,系统提示: “你的账户可能存在问题,需要解决后才能继续注册流程。请与支持团队联系。” 反馈客服 客服就说这是系统自动处理的人工无法干预 我问他那我怎么注册 他也不说怎么解决 就说你注册不了 问原因就说只能看到系统提示 具体什么原因也不清楚 现在情况 1.注册的设备登录id的时候 原本注册按钮就显示 “未能成功验证身份证” 2.换设备登录 又变成注册按钮 想问一下各位佬 有没有遇到过类似情况 后面成功注册的? 有什么有效的解决办法绕过系统拦截吗? 1 个帖子 - 1 位参与者 阅读完整话题
个人的第一个浏览器插件已经提交审核了,有点小激动啊,不知道啥时候能审核通过。 下面内容部分AI润色 --功能介绍 网页珍藏是一款本地优先的网页 Markdown 珍藏工具。 --开发目的 解决平时一些好的文章,不能快速保存到本地,导致后期原链接不删除找不到内容,或者是不利于批量维护检索的问题 它适合需要长期整理网页资料、技术文章、产品案例、论坛内容、研究材料和知识库笔记的用户。你可以从当前标签页本地提取正文,生成可编辑 Markdow,方便维护,发布思源笔记或导出为本地 Markdown 文件,建立一个本地网页珍藏索引,同时也支持AI内容提炼,整理 核心能力: 1. 本地正文提取 网页内容在浏览器本地提取,不自动上传到开发者服务器,内置智能段落提取 2. Markdown 预览与编辑 提取出的原文 Markdown 可以直接预览,也可以切换到编辑模式。 3. 点选区块 自动提取不准时,可以像审查元素一样点选页面区域,只保存真正需要的正文。 4. 用户提取规则 点选区块可以保存为站点规则,下次访问相似页面时优先复用,越用越智能 5. 本地珍藏库 保存成功后,网页会写入扩展内置珍藏库,支持搜索和管理。 6. 本地 Markdown 导出 可以把网页保存为本地 Markdown 文件,并尽量本地化远程图片,自动网址分类,支持快速搜索 7. 可选 AI 提炼 AI 默认不会自动发送正文。只有用户配置自己的 OpenAI 兼容接口并点击 AI 整理后,才会发送当前正文。 隐私边界: 不读取浏览历史,不收集 Cookie、密码或表单数据,不执行远程 JavaScript,不静默发送网页正文给 AI。开发者服务器不接收网页正文、Token 或 API Key。 功能预览图 演示视频 bilibili.com 视频去哪了呢?_哔哩哔哩_bilibili undefined, 视频播放量 undefined、弹幕量 undefined、点赞数 undefined、投硬币枚数 undefined、收藏人数 undefined、转发人数 undefined, 视频作者 undefined, 作者简介 undefined,相关视频: 1 个帖子 - 1 位参与者 阅读完整话题
不知道为何github学生认证掉了,不得不费劲又重新搞了一下,提交了四次才重新成功。 附我的提交过程: 申请过程要定位,试了下via,Chrome,Edge在手机上点那个定位都会莫名失败,最后去 play 上下了个Firefox才能正常定位。 认证资料选8.Other 拍照用学信网的学籍认证英文版本(似乎听别人说用ai翻译的也行),我用pdf/word改一下排版, 放大字号 并集中紧凑一下方便一张照片拍全。 怀念以前github学生认证给的token额度了 1 个帖子 - 1 位参与者 阅读完整话题
比较习惯 JetBrains 的 git 提交逻辑,但是 VSCode 上没找到类似的,于是 Vibe Coding 了一个,已上架插件商店 GitConstellation https://marketplace.visualstudio.com/items?itemName=flybugxyz.vscode-git-constellation
比较习惯 JetBrains 的 git 提交逻辑,但是 VSCode 上没找到类似的,于是 Vibe Coding 了一个,已上架插件商店 GitConstellation https://marketplace.visualstudio.com/items?itemName=flybugxyz.vscode-git-constellation
比较习惯 JetBrains 的 git 提交逻辑,但是 VSCode 上没找到类似的,于是 Vibe Coding 了一个,已上架插件商店 GitConstellation https://marketplace.visualstudio.com/items?itemName=flybugxyz.vscode-git-constellation
被封了号的还没到期的,快去登登看。好像都回来了,原来的订阅还有效 我是提交了申诉的,没申诉的不清楚。已经退款了的应该也不行了。 补充一下: 有小伙伴反应没回来,所以我感觉可能要申诉一下。 申诉怎么写的:我让AI帮我写的,就是让他看一下官方的条款,然后说明一下自己的情况,让它分析一下可能的原因,然后写申诉 3 个帖子 - 2 位参与者 阅读完整话题
我看有个佬分享说可以删账号再用原邮箱注册 但是我现在一直卡在on hold只能提交request 有没有啥办法可以直接删账号啊 5 个帖子 - 3 位参与者 阅读完整话题
年初的时候 openclaw 刚火起来,我们惊讶于 Peter Steinberger 每天的开发效率,OpenClaw 峰值有 1 天 merge 600 commits 。我们便开始重新回顾和研究,开始一步步的向更高效率靠拢。 这小半年来我们从每天个位数的提交提升到数十次,巅峰期某天达到上百次,效率明显提升,所以我们把一点点的经验和使用的工具 Agentflow 和 it-runner 分享给大家。 (如下是 Agentflow 项目的最近提交截图) 先聊开发过程中的几个问题: 1 、手动操作和记忆消耗 AI 写代码的能力越来越强,但真正落到具体业务开发时,很多事情还是停留在“人肉调度”的阶段。 某个硬件或服务端项目,需要记住端口、环境变量、部署路径、上传方式、重启命令; 调试业务问题时,不只是跑单元测试,还要看日志、清缓存、重启服务、反复验证; 很多任务需要长期或重复运行,比如构建、部署、联调; AI 改完代码后,最好能自动执行固定流程,失败后继续让 AI 分析、修复、再验证, 避免开发者手动收集信息再转交。 这些事情单独看都不复杂,但每天重复很多次,就会非常消耗精力和考验记忆力 2 、并行开发和团队协作 我们经常同时跑多个项目、多个任务、多个 Agent 。以前用 tmux 、Cursor 、VSCode 也能跑,但时间一长会遇到几个问题: 不方便管理多个项目和多个任务; 不方便回看每个任务的历史记录; 不方便在手机或远程环境里查看进度、接手操作; 多个 Agent 同时改代码时,分支、目录、上下文容易混乱。 团队成员共同开发一个任务时,不方便共享上下文,也不容易看到其他人是怎么思考和推进任务的。 这一点在多人协作时尤其明显。 以前一个任务如果是多个人一起参与,很多上下文其实散落在微信群、飞书、终端记录、Git commit 、PR 描述里。别人要接手时,往往只能看到最终代码,很难知道前面是怎么思考的、试过哪些方案、为什么这么改。 在 AgentFlow 里,同一个任务可以由团队成员共享同一个面板。大家可以直接看到任务进度、Agent 的执行过程、其他成员写过的提示词、运行结果和后续处理思路。 这个体验对团队内部协作非常有帮助:新接手项目成员可以更快理解老成员是怎么拆解问题、怎么给 AI 下指令的;多人协作时,不需要反复同步“现在做到哪一步了”;接手任务的人也可以直接沿着前面的上下文继续推进。 成果分享 可以说 AgentFlow 不只是一个 AI 开发工具,也像是一个团队共同沉淀开发过程和业务理解的地方。 最终我们基于一些 kanban 类产品的使用体验,再结合内部孵化的 it-runner ,做出了 AgentFlow 。 官网地址: https://agentflow.geili.ai/ 简单来说,AgentFlow 想解决的是: 在浏览器里 ALL in One, 管理多个 AI 开发任务,让每个任务都可以独立运行、查看历史、执行固定流程,并且内置的 it-runner 完成测试、构建、部署、看日志、重启服务等动作。让开发者尽可能少的频繁切换终端或者 IDE. 以前很多任务需要人盯着终端一步一步操作,现在可以把流程固定下来,让 AI 改代码、跑命令、看结果、再继续修。我们团队现在每天 git commit 的次数明显变多,需求和修复都能更快推进,确实有点找到了点龙虾作者的感觉,当然还有非常大的距离。 我们也把 AgentFlow 推荐给了一些合作伙伴试用,反馈还不错。 当然,用下来也发现了一些问题。 在 AI 并行开发场景里,代码隔离非常重要。我们的体感是:一旦同时跑 2 ~ 3 个以上任务,git worktree 的收益就会非常明显。每个任务有独立目录,互相不影响,Agent 改坏了也比较好处理。 很多 kanban 类 AI 开发工具会强制使用 worktree 。 但我们发现,一部分开发者刚开始并不太习惯 worktree ,所以 AgentFlow 目前同时支持两种方式: 使用 git worktree ,适合多任务并行开发; 不使用 worktree ,适合刚开始体验或简单任务。 这样新手上手会更容易一些。等任务数量变多后,再逐步切换到 worktree 模式也可以。 免费及开源 目前 AgentFlow 还没有收费计划,先开放给大家体验,收集更多真实反馈。 https://agentflow.geili.ai/ it-runner 接下来也有开源的计划,可以先查看 https://agentflow.geili.ai/docs/it-runner/ 了解 我们也建了一个早期用户交流群,原因大家反馈问题、交流使用方式。后续如果有收费计划,早期用户会优先获得兑换码、优惠资格或较长时间的免费额度。 愿意支持顶贴的朋友,也可以留下邮箱,后续我们如上线兑换码时会统一发送 以下是 AgentFlow 的一些截图: 每个分支都可以单独运行相关 it-runner 任务 任务出错时,可以一键 ai 修复,并重启 每个分支独立的 git 、终端、文件等操作,不再需要打开 IDE 增加了新手引导,让上手更简单
年初的时候 openclaw 刚火起来,我们惊讶于 Peter Steinberger 每天的开发效率,OpenClaw 峰值有 1 天 merge 600 commits 。我们便开始重新回顾和研究,开始一步步的向更高效率靠拢。 这小半年来我们从每天个位数的提交提升到数十次,巅峰期某天达到上百次,效率明显提升,所以我们把一点点的经验和使用的工具 Agentflow 和 it-runner 分享给大家。 (如下是 Agentflow 项目的最近提交截图) 先聊开发过程中的几个问题: 1 、手动操作和记忆消耗 AI 写代码的能力越来越强,但真正落到具体业务开发时,很多事情还是停留在“人肉调度”的阶段。 某个硬件或服务端项目,需要记住端口、环境变量、部署路径、上传方式、重启命令; 调试业务问题时,不只是跑单元测试,还要看日志、清缓存、重启服务、反复验证; 很多任务需要长期或重复运行,比如构建、部署、联调; AI 改完代码后,最好能自动执行固定流程,失败后继续让 AI 分析、修复、再验证, 避免开发者手动收集信息再转交。 这些事情单独看都不复杂,但每天重复很多次,就会非常消耗精力和考验记忆力 2 、并行开发和团队协作 我们经常同时跑多个项目、多个任务、多个 Agent 。以前用 tmux 、Cursor 、VSCode 也能跑,但时间一长会遇到几个问题: 不方便管理多个项目和多个任务; 不方便回看每个任务的历史记录; 不方便在手机或远程环境里查看进度、接手操作; 多个 Agent 同时改代码时,分支、目录、上下文容易混乱。 团队成员共同开发一个任务时,不方便共享上下文,也不容易看到其他人是怎么思考和推进任务的。 这一点在多人协作时尤其明显。 以前一个任务如果是多个人一起参与,很多上下文其实散落在微信群、飞书、终端记录、Git commit 、PR 描述里。别人要接手时,往往只能看到最终代码,很难知道前面是怎么思考的、试过哪些方案、为什么这么改。 在 AgentFlow 里,同一个任务可以由团队成员共享同一个面板。大家可以直接看到任务进度、Agent 的执行过程、其他成员写过的提示词、运行结果和后续处理思路。 这个体验对团队内部协作非常有帮助:新接手项目成员可以更快理解老成员是怎么拆解问题、怎么给 AI 下指令的;多人协作时,不需要反复同步“现在做到哪一步了”;接手任务的人也可以直接沿着前面的上下文继续推进。 成果分享 可以说 AgentFlow 不只是一个 AI 开发工具,也像是一个团队共同沉淀开发过程和业务理解的地方。 最终我们基于一些 kanban 类产品的使用体验,再结合内部孵化的 it-runner ,做出了 AgentFlow 。 官网地址: https://agentflow.geili.ai/ 简单来说,AgentFlow 想解决的是: 在浏览器里 ALL in One, 管理多个 AI 开发任务,让每个任务都可以独立运行、查看历史、执行固定流程,并且内置的 it-runner 完成测试、构建、部署、看日志、重启服务等动作。让开发者尽可能少的频繁切换终端或者 IDE. 以前很多任务需要人盯着终端一步一步操作,现在可以把流程固定下来,让 AI 改代码、跑命令、看结果、再继续修。我们团队现在每天 git commit 的次数明显变多,需求和修复都能更快推进,确实有点找到了点龙虾作者的感觉,当然还有非常大的距离。 我们也把 AgentFlow 推荐给了一些合作伙伴试用,反馈还不错。 当然,用下来也发现了一些问题。 在 AI 并行开发场景里,代码隔离非常重要。我们的体感是:一旦同时跑 2 ~ 3 个以上任务,git worktree 的收益就会非常明显。每个任务有独立目录,互相不影响,Agent 改坏了也比较好处理。 很多 kanban 类 AI 开发工具会强制使用 worktree 。 但我们发现,一部分开发者刚开始并不太习惯 worktree ,所以 AgentFlow 目前同时支持两种方式: 使用 git worktree ,适合多任务并行开发; 不使用 worktree ,适合刚开始体验或简单任务。 这样新手上手会更容易一些。等任务数量变多后,再逐步切换到 worktree 模式也可以。 免费及开源 目前 AgentFlow 还没有收费计划,先开放给大家体验,收集更多真实反馈。 https://agentflow.geili.ai/ it-runner 接下来也有开源的计划,可以先查看 https://agentflow.geili.ai/docs/it-runner/ 了解 我们也建了一个早期用户交流群,原因大家反馈问题、交流使用方式。后续如果有收费计划,早期用户会优先获得兑换码、优惠资格或较长时间的免费额度。 愿意支持顶贴的朋友,也可以留下邮箱,后续我们如上线兑换码时会统一发送 以下是 AgentFlow 的一些截图: 每个分支都可以单独运行相关 it-runner 任务 任务出错时,可以一键 ai 修复,并重启 每个分支独立的 git 、终端、文件等操作,不再需要打开 IDE 增加了新手引导,让上手更简单