From 1636ac1ff717b390ab22f199eafa14e32bb98dba Mon Sep 17 00:00:00 2001 From: user3301 <26126682+user3301@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:06:59 +1100 Subject: [PATCH 1/4] Keep AI Micro bridge running independently of App sessions Separate HID lifetime from the session status feed with an authenticated local daemon, cached navigation and automatic feed recovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extensions/ai-micro-copilot/.gitignore | 2 + .github/extensions/ai-micro-copilot/README.md | 24 ++- .../ai-micro-copilot/bridge-client.mjs | 109 +++++++++++++ .../ai-micro-copilot/bridge-rpc.mjs | 83 ++++++++++ .../ai-micro-copilot/bridge-service.mjs | 144 ++++++++++++++++++ .../ai-micro-copilot/controller.mjs | 15 +- .../extensions/ai-micro-copilot/daemon.mjs | 65 ++++++++ .../extensions/ai-micro-copilot/extension.mjs | 101 ++---------- .../extensions/ai-micro-copilot/package.json | 3 +- .../tests/bridge-client.test.mjs | 68 +++++++++ .../tests/fixtures/feed-worker.mjs | 10 ++ .../tests/fixtures/persistent-worker.mjs | 30 ++++ .../tests/persistent.test.mjs | 119 +++++++++++++++ README.md | 32 ++-- 14 files changed, 698 insertions(+), 107 deletions(-) create mode 100644 .github/extensions/ai-micro-copilot/bridge-client.mjs create mode 100644 .github/extensions/ai-micro-copilot/bridge-rpc.mjs create mode 100644 .github/extensions/ai-micro-copilot/bridge-service.mjs create mode 100644 .github/extensions/ai-micro-copilot/daemon.mjs create mode 100644 .github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs create mode 100644 .github/extensions/ai-micro-copilot/tests/fixtures/feed-worker.mjs create mode 100644 .github/extensions/ai-micro-copilot/tests/fixtures/persistent-worker.mjs create mode 100644 .github/extensions/ai-micro-copilot/tests/persistent.test.mjs diff --git a/.github/extensions/ai-micro-copilot/.gitignore b/.github/extensions/ai-micro-copilot/.gitignore index b6f6012..0c18e7a 100644 --- a/.github/extensions/ai-micro-copilot/.gitignore +++ b/.github/extensions/ai-micro-copilot/.gitignore @@ -1,3 +1,5 @@ node_modules/ config.json +.bridge-runtime.json +.bridge-runtime.json.tmp config.json.tmp diff --git a/.github/extensions/ai-micro-copilot/README.md b/.github/extensions/ai-micro-copilot/README.md index 5041237..64b9036 100644 --- a/.github/extensions/ai-micro-copilot/README.md +++ b/.github/extensions/ai-micro-copilot/README.md @@ -7,7 +7,7 @@ ```text AI Micro(兼容的出厂固件或 Basic codex 示例) USB 或 Windows 蓝牙 Vendor HID Report 6 - 本地 ai-micro-copilot 扩展 + 独立 daemon(HID、导航、配置)← 已认证 Windows 命名管道 ← 会话扩展(状态源) Copilot SDK session.rpc.tools.execute get_sessions_status / list_sessions_and_chats / create_session App 返回的 ghapp:// 会话链接 → Windows 注册的 App 处理器 @@ -36,6 +36,8 @@ npm test 让 Copilot 重新加载扩展,再检查 `ai-micro-copilot` 为 ready。SDK 由 App 提供,不要自行安装另一个 `@github/copilot-sdk`。 +**先在独立 Windows 终端启动服务**:进入同一扩展目录,运行 `npm run daemon` 并保持终端运行。此时只开放管理接口,尚未连接键盘;之后通过扩展配置并 `start`。不要把服务作为可回收 CLI 的普通子进程运行;由 Agent 启动时必须明确使用独立 detached 进程并检查就绪状态。不安装开机自启,不自动创建 Agent 任务。 + 本次实机使用兼容的出厂固件,**无需刷机**。硬件与固件信息请参阅[厂商文档](https://micro.diyshare.cn/)。桥接不会执行烧录、修改分区或改变原有 USB / BLE 协议;不要为安装本扩展而擦除或替换固件。 ## 配置和启动 @@ -48,9 +50,9 @@ npm test | `devices` | 枚举 USB / 蓝牙 Vendor HID,返回 transport、serialNumber;枚举到配对记录不代表握手成功 | | `configure` | 验证并保存 `config` 更新;不启动桥接 | | `bind` | 使用 `slot`(1–6)和 `sessionId`(ID 或 null)仅修改一个键,保存并即时应用,不影响其他键 | -| `start` | 获取本机单实例锁,握手设备,开始状态轮询和按键监听 | +| `start` | 要求独立服务已启动,获取新鲜 App 状态后握手设备,开始按键监听 | | `status` | 连接方式、设备序列号、固件版本、六槽绑定、最近错误,以及 `backgroundIdle` / `backgroundIdleMs`;这是缓存,不会主动刷新 App | -| `stop` | 停止监听,尽力熄灯,关闭 HID,释放锁 | +| `stop` | 停止监听,尽力熄灯,关闭 HID;独立服务保留管理接口和单实例锁 | 先让 Copilot 列出会话,由你指定按键 1–6 的顺序;不能擅自取列表前六项。示例: @@ -80,7 +82,11 @@ npm test 随后 `start`。只更新连接设置会保留全部绑定。同一设备的 USB 与蓝牙序列号可能不同,不能互用。更改连接方式或序列号前必须停桥接,不会自动切换 USB/蓝牙,也不会在重连时改选其他设备。握手还检查固件实际报告的活动连接方式。 -配置后调用 `start`。绑定至少一个会话才能启动。**启动状态不持久化**,重新加载扩展、重启 App 或结束承载扩展的会话后,需要再次启动;绑定和亮度会保留。只需一个本地会话承载桥接,不需要六个会话都安装扩展。Windows 命名管道锁防止本用户的多个扩展实例争用设备。 +配置后调用 `start`。绑定至少一个会话才能启动。**硬件运行状态属于独立服务**:会话被回收或扩展重载时不关闭 HID,已验证链接仍可导航;扩展重新加载后自动恢复状态推送,不需要重新 `start`。电脑重启或独立服务退出后,需要重新启动服务和硬件。显式 `stop` 不会被状态重连撤销。只需一个会话提供状态,不需要六个会话都安装扩展。Windows 命名管道防止本用户多个桥接争用设备,旧版桥接必须先停止再迁移。 + +`status` 增加 `daemonPid`、`appConnected` 和 `navigationMode`(`live` / `cached`)。状态连接断开会立即失效,静默卡住时在 `max(5000, pollMs * 2 + 1000)` 毫秒后失效,灯在下一次设备更新显示紫色未知。离线期间 Agent 键、上一/下一、回到选中会话仍使用最后验证的链接;等待处理、新建会话和配置修改需新鲜状态,否则明确报错。缓存目标可能已删除,不保证仍可打开,但不会跳到别的会话。导航不发送模型消息,不保证 App 立即重新加载扩展;状态源真正回来前灯保持未知。 + +没有 App 时可从同目录运行 `node daemon.mjs --stop` 停止硬件;独立终端 Ctrl+C 退出服务。`.bridge-runtime.json` 是忽略版本控制的本地凭据文件,禁止分享;不包含 GitHub Token。该接口仅用于本机可信用户进程,不是同用户恶意进程的安全沙箱。 ### 通过对话动态绑定 @@ -96,7 +102,7 @@ npm test 取消绑定使用 `"sessionId": null`。工具串行校验、原子保存 `config.json`,然后同步内存配置和灯光;桥接运行中不需要重启,之后按键使用新绑定。重复 ID、无效槽位、不存在的会话及写盘失败会明确报错,不覆盖当前绑定。已保存但灯光同步失败时会明确提示“配置已保存”,并在恢复连接后重新同步。 -**没有文件监听器**:Agent 应调用工具,而不是直接改 JSON 文件。手工编辑磁盘文件需先停止并重新加载扩展,再启动才生效。工具只出现在加载了此扩展的会话中;不保证所有项目会话都直接具备它。 +**没有文件监听器**:Agent 应调用工具,而不是直接改 JSON 文件。手工编辑磁盘文件需先停止硬件并重启独立服务,再启动才生效。工具只出现在加载了此扩展的会话中;不保证所有项目会话都直接具备它。 ## 按键与灯光 @@ -118,9 +124,9 @@ npm test 会话切换不使用 `navigate_to`,因为实测该工具只显示需要点击 **Open** 的卡片。扩展从 `list_sessions_and_chats` 取得所选 ID 的真实 `app_url`,校验为同一 ID 的 `ghapp://sessions/...` 链接,再交给 Windows 的 App 注册入口,因此物理按键可直接切换。不会拼造 URL,不接受网页地址或命令参数;PowerShell 脚本是固定文本,URL 通过标准输入作为数据传入,不执行为脚本。 -为减少切换延迟,Agent 键、上一/下一及回到选中会话复用 **不足 2.5 秒**的成功状态快照;过期或轮询失败后必须重新读取,等待处理等其他动作仍实时刷新。App 会话链接目录缓存 **30 秒**,启动及状态轮询会在过期时刷新;修改绑定/配置、读取失败或导航失败后清空,下次使用重新查询。失效会话可能在短暂缓存窗口内尚未被察觉,但不会改跳其他会话。 +为减少切换延迟,在线时 Agent 键、上一/下一及回到选中会话复用 **不足 2.5 秒**的成功状态快照,过期时读取独立服务收到的新鲜状态。扩展每 **30 秒**刷新 App 链接目录,启动、绑定和配置时强制刷新。状态源失联时,独立服务在本次进程生命周期内保留最后验证的目录,仅用于普通导航,不保留“实时”灯色;无效目录更新会撤销缓存。失效会话可能在缓存期间尚未被察觉,但不会改跳其他会话。链接不写入磁盘,服务重启后必须重新取得。 -桥接启动时预热一个隐藏的 PowerShell URI 辅助进程,按键复用该进程,正常停止或扩展退出时关闭。每次打开仍等待 Windows 调用成功响应;异常、退出或 10 秒超时会明确报错,不自动重放这次导航,下一次操作可重新启动辅助进程。这不是独立常驻桥接,不能解决 App 回收宿主会话导致桥接停止的问题。输入仍与轮询串行执行,App 页面加载、HID 唤醒和正在执行的慢轮询也可能影响总延迟。 +桥接启动时预热一个隐藏的 PowerShell URI 辅助进程,按键复用该进程,显式停止或独立服务退出时关闭;会话扩展退出不关闭它。每次打开仍等待 Windows 调用成功响应;异常、退出或 10 秒超时会明确报错,不自动重放这次导航,下一次操作可重新启动辅助进程。App 页面加载、HID 唤醒和正在执行的设备请求仍可能影响总延迟。 可以通过 `config.keys` 把上述功能键映射到表中动作,或 `none` 禁用。不接受任意命令、权限批准、自动发消息等动作。例如禁用创建 worktree:`{"operation":"configure","config":{"keys":{"ACT09":"none"}}}`。 @@ -158,6 +164,8 @@ App 的 `is_running=false` 不能单独代表会话离线:正常回答结束 软件测试覆盖分帧、UTF-8、4096 字节上限、异常数据、固定六槽、灯色优先级、两种连接方式的选择与握手、旋钮 `act=2`、动态单键绑定与并发更新、保存失败、设备身份固定重连、丢弃旧连接操作和退出。空闲灯光测试覆盖 5 分钟阈值前后、Agent 灯继续更新、输入唤醒并执行一次、禁用控件唤醒、可配置/关闭计时、重连不误亮及错误恢复。Mock HID 测试不能代替实机验收。 +独立桥接回归使用两个真实 Node 进程和 Windows 命名管道:终止状态源进程后,HID 所有者继续处理模拟按键并显示未知;重新连接恢复状态,显式停止后不会被状态重连撤销。覆盖凭据拒绝、单状态源、超时过期、无效链接和会话移除。实机 USB 验证(2026-10-02):只终止会话扩展,用户确认 Agent 3 / Agent 6 仍可切换、灯变紫色;重载扩展后,无需再次 `start` 即恢复状态,独立服务 PID 和连接保持不变。这不等于已验证 Windows 睡眠/唤醒或长时间蓝牙稳定性。 + 本次实机联调:最初只发现 BLE,更换数据线并直连电脑后发现 USB;已读取出厂固件版本、通过握手并成功下发灯光。用户确认等待回答时 Agent 1 灯光符合预期,以及从其他会话按 Agent 1 可直接切回,无需点击 Open。桥接运行时状态为 connected、非 stale、无错误。未刷写设备。 现有出厂固件蓝牙探针实测:35 次状态/版本/六槽灯光请求全部成功,延迟中位数约 75 ms、最大 587 ms;三次关闭/重开 HID 句柄成功。用户确认六键测试颜色,已捕获六个 Agent 键、旋钮按压、32 个旋转事件及摇杆四方向。 @@ -178,4 +186,4 @@ App 的 `is_running=false` 不能单独代表会话离线:正常回答结束 会话列表较大时,App 可能返回 `Output too large to read at once ... Saved to: ...`,而不是直接返回 JSON。扩展会读取该次工具输出的完整临时文件,再解析列表或状态;不会使用截断的预览,也不会猜测会话链接。仅接受本机临时目录下符合 App 命名规则的输出文件,大小上限为 16 MiB;读取过程中同样强制限长,最多读取 16 MiB 加 1 字节以检测超限,即使文件在大小检查后增长也会拒绝。文件丢失或格式不支持时明确报错。旧版本出现 `Unexpected token 'O'` 时,应更新扩展并重新加载、启动桥接;删除会话或重新配对蓝牙不是解决办法。 -**原基础固件没有主机心跳超时熄灯功能,出厂固件的异常退出行为尚未验证**:正常 `stop` 会发送熄灯指令,但 App 崩溃、强杀进程或电脑睡眠可能使键盘保留最后一次灯色。此时灯光不能作为实时状态依据。重启桥接后全量同步;长期可靠离线指示需要后续验证或增加固件 watchdog。本版不声称解决这一限制。 +**原基础固件没有主机心跳超时熄灯功能,出厂固件的异常退出行为尚未验证**:独立服务存活时,App 状态源消失会显示未知;但强杀独立服务、服务崩溃或电脑睡眠仍可能使键盘保留最后一次灯色。正常 `stop` 会发送熄灯指令。固件级可靠离线指示仍需后续验证或 watchdog,本版不声称解决这一限制。 diff --git a/.github/extensions/ai-micro-copilot/bridge-client.mjs b/.github/extensions/ai-micro-copilot/bridge-client.mjs new file mode 100644 index 0000000..23d3f38 --- /dev/null +++ b/.github/extensions/ai-micro-copilot/bridge-client.mjs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +import { readFile } from "node:fs/promises"; +import { callApp, readSessions } from "./core.mjs"; +import { connectBridge } from "./bridge-rpc.mjs"; + +export class BridgeClient { + constructor({ session, runtimePath, configPath, log }) { + this.session = session; this.runtimePath = runtimePath; this.configPath = configPath; this.log = log; + this.peer = null; this.catalogue = null; this.expires = 0; + this.queue = Promise.resolve(); this.closed = false; this.lastError = ""; + this.pollMs = 2000; + } + serialize(operation) { + const result = this.queue.then(() => { + if (this.closed) throw new Error("App bridge client stopped"); + return operation(); + }); + this.queue = result.catch(() => {}); + return result; + } + async connect() { + if (this.peer && !this.peer.closed) return this.peer; + let runtime; + try { runtime = JSON.parse(await readFile(this.runtimePath, "utf8")); } + catch (error) { + if (error.code === "ENOENT") + throw new Error("Independent bridge is not running. Start node daemon.mjs in a separate persistent process first."); + throw error; + } + if (runtime.protocol !== 1 || runtime.configPath !== this.configPath || + typeof runtime.token !== "string" || !/^[a-f0-9]{64}$/.test(runtime.token) || + typeof runtime.endpoint !== "string" || !/^\\\\\.\\pipe\\ai-micro-copilot-[a-f0-9]{16}$/.test(runtime.endpoint)) + throw new Error("Invalid independent bridge discovery file"); + const peer = await connectBridge(runtime, async (method, args) => { + if (method !== "create-session" || !args || typeof args.project_id !== "string" || + args.name !== "AI Micro session" || args.coordinate_with_creator !== false || + Object.keys(args).some(key => !["project_id", "name", "coordinate_with_creator"].includes(key))) + throw new Error("Unsupported App bridge action"); + return callApp(this.session, "create_session", args); + }); + if (this.closed) { peer.close(); throw new Error("App bridge client stopped"); } + this.peer = peer; this.catalogue = null; this.expires = 0; + return peer; + } + async publish(peer, forceCatalogue = false) { + await peer.request("attach"); + try { + const sessions = await readSessions(this.session); + if (forceCatalogue || this.catalogue === null || performance.now() >= this.expires) { + const result = await callApp(this.session, "list_sessions_and_chats"); + const catalogue = typeof result === "string" + ? JSON.parse(result.replace(/^Found \d+ item\(s\):\s*/, "")) : result; + if (!Array.isArray(catalogue)) throw new Error("Unsupported App catalogue format"); + this.catalogue = catalogue.map(item => ({ id: item?.id, app_url: item?.app_url })); + this.expires = performance.now() + 30000; + } + // Do not send prompts, conversation bodies, or full pending-input objects to the daemon. + const compact = sessions.map(s => ({ + id: s.id, name: s.name, project_id: s.project_id, is_running: s.is_running, + awaiting_user_input: Boolean(s.awaiting_user_input), + awaiting_plan_approval: Boolean(s.awaiting_plan_approval), + activity: { status: s.activity?.status }, + })); + await peer.request("snapshot", { sessions: compact, catalogue: this.catalogue }); + } catch (error) { + this.catalogue = null; + if (!peer.closed) { + try { await peer.request("unavailable"); } + catch (offlineError) { this.log(`Cannot mark App feed unavailable: ${offlineError.message}`, "error"); } + } + throw error; + } + } + request(operation, params) { + return this.serialize(async () => { + const peer = await this.connect(); + if (["start", "bind", "configure"].includes(operation)) await this.publish(peer, true); + const result = await peer.request(operation, params); + if (result?.pollMs) this.pollMs = result.pollMs; + return result; + }); + } + start() { + const poll = async () => { + try { + await this.serialize(async () => { + const peer = await this.connect(); + const status = await peer.request("status"); + this.pollMs = status.pollMs ?? 2000; + if (status.running) await this.publish(peer); + if (this.lastError) { + this.lastError = ""; this.log("Independent bridge App feed recovered", "info"); + } + }); + } catch (error) { + if (!this.closed && this.lastError !== error.message) { + this.lastError = error.message; this.log(error.message, "error"); + } + } finally { + if (!this.closed) this.timer = setTimeout(poll, this.pollMs); + } + }; + void poll(); + } + close() { + this.closed = true; clearTimeout(this.timer); + this.peer?.close(); + } +} diff --git a/.github/extensions/ai-micro-copilot/bridge-rpc.mjs b/.github/extensions/ai-micro-copilot/bridge-rpc.mjs new file mode 100644 index 0000000..395ec3e --- /dev/null +++ b/.github/extensions/ai-micro-copilot/bridge-rpc.mjs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +import { EventEmitter } from "node:events"; +import { createConnection } from "node:net"; + +const limit = 1024 * 1024; + +export class BridgePeer extends EventEmitter { + constructor(socket, handle = async () => { throw new Error("Unsupported bridge request"); }) { + super(); + this.socket = socket; this.handle = handle; + this.pending = new Map(); this.sequence = 0; this.closed = false; + let buffer = "", active = 0; + socket.setEncoding("utf8"); + socket.on("data", chunk => { + buffer += chunk; + if (Buffer.byteLength(buffer) > limit) return this.close(new Error("Bridge frame exceeds 1 MiB")); + let end; + while ((end = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + let frame; + try { frame = JSON.parse(line); } + catch { this.close(new Error("Invalid bridge JSON")); return; } + if (!frame || !Number.isSafeInteger(frame.id) || frame.id < 1) { + this.close(new Error("Invalid bridge message")); return; + } + if (frame.type === "reply") { + const pending = this.pending.get(frame.id); + if (!pending) continue; + this.pending.delete(frame.id); clearTimeout(pending.timer); + if (frame.error) pending.reject(new Error(String(frame.error))); + else pending.resolve(frame.result); + } else if (frame.type === "request" && typeof frame.method === "string" && active < 16) { + active++; + Promise.resolve().then(() => this.handle(frame.method, frame.params)).then( + result => this.send({ type: "reply", id: frame.id, result }), + error => this.send({ type: "reply", id: frame.id, error: error.message }), + ).finally(() => { active--; }).catch(error => this.close(error)); + } else { this.close(new Error("Invalid or excessive bridge request")); return; } + } + }); + socket.on("error", error => this.close(error)); + socket.on("close", () => this.close(new Error("Bridge connection closed"))); + } + send(frame) { + if (this.closed) throw new Error("Bridge connection closed"); + const data = JSON.stringify(frame) + "\n"; + if (Buffer.byteLength(data) > limit || this.socket.writableLength > limit) + throw new Error("Bridge message or write backlog exceeds 1 MiB"); + this.socket.write(data); + } + request(method, params, timeoutMs = 15000) { + if (this.closed) return Promise.reject(new Error("Bridge connection closed")); + if (this.pending.size >= 16) return Promise.reject(new Error("Bridge request queue full")); + const id = ++this.sequence; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.close(new Error(`Bridge ${method} timed out; request was not retried`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + try { this.send({ type: "request", id, method, params }); } + catch (error) { this.close(error); } + }); + } + close(error = new Error("Bridge connection closed")) { + if (this.closed) return; + this.closed = true; + this.socket.destroy(); + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); pending.reject(error); + } + this.pending.clear(); + this.emit("close", error); + } +} + +export async function connectBridge({ endpoint, token }, handle) { + const socket = createConnection(endpoint); + const peer = new BridgePeer(socket, handle); + try { + await peer.request("authenticate", { token }, 5000); + return peer; + } catch (error) { peer.close(); throw error; } +} diff --git a/.github/extensions/ai-micro-copilot/bridge-service.mjs b/.github/extensions/ai-micro-copilot/bridge-service.mjs new file mode 100644 index 0000000..6467730 --- /dev/null +++ b/.github/extensions/ai-micro-copilot/bridge-service.mjs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +import { createServer } from "node:net"; +import { timingSafeEqual } from "node:crypto"; +import { Controller } from "./controller.mjs"; +import { parseSnapshot } from "./core.mjs"; +import { sessionUrl } from "./navigation.mjs"; +import { BridgePeer } from "./bridge-rpc.mjs"; + +export class BridgeService { + constructor({ config, token, open, navigate, save, log, now = () => performance.now(), + prepare = async () => {}, release = () => {} }) { + this.token = token; this.now = now; this.log = log; + this.feed = null; this.receivedAt = -Infinity; + this.sessions = []; this.catalogue = []; + this.prepare = prepare; this.release = release; + this.peers = new Set(); + this.controls = Promise.resolve(); + this.controller = new Controller({ + config, open, save, log, now, + readSessions: async () => { + if (!this.fresh()) throw new Error("App status unavailable; cached navigation only"); + return this.sessions; + }, + offlineNavigation: () => !this.fresh(), + navigate: async id => { await navigate(sessionUrl(this.catalogue, id)); }, + call: async (name, args) => { + if (name !== "create_session" || !this.fresh()) throw new Error("App status unavailable"); + return this.feed.request("create-session", args); + }, + }); + } + fresh() { + return Boolean(this.feed && !this.feed.closed && + this.now() - this.receivedAt < Math.max(5000, this.controller.config.pollMs * 2 + 1000)); + } + status() { + const status = this.controller.status(); + return { ...status, daemonPid: process.pid, pollMs: this.controller.config.pollMs, appConnected: this.fresh(), + navigationMode: this.fresh() ? "live" : "cached", + stale: status.stale || !this.fresh(), + slots: status.slots.map(slot => slot.sessionId !== null && !this.fresh() + ? { ...slot, state: "unknown" } : slot) }; + } + async reconcile() { + if (this.controller.running) await this.controller.tick(); + } + async listen(endpoint) { + this.server = createServer(socket => { + let authenticated = false; + const peer = new BridgePeer(socket, async (method, params) => { + if (!authenticated) { + const supplied = Buffer.from(typeof params?.token === "string" ? params.token : ""); + const expected = Buffer.from(this.token); + if (method !== "authenticate" || supplied.length !== expected.length || + !timingSafeEqual(supplied, expected)) throw new Error("Bridge authentication failed"); + authenticated = true; clearTimeout(authTimer); + return { protocol: 1 }; + } + if (["start", "stop", "bind", "configure"].includes(method)) { + const result = this.controls.then(() => this.handle(peer, method, params)); + this.controls = result.catch(() => {}); + return result; + } + return this.handle(peer, method, params); + }); + this.peers.add(peer); + const authTimer = setTimeout(() => peer.close(new Error("Bridge authentication timed out")), 5000); + peer.on("close", () => { + clearTimeout(authTimer); this.peers.delete(peer); + if (this.feed === peer) { + this.feed = null; this.receivedAt = -Infinity; + this.controller.stale = true; + this.controller.enqueue(() => this.reconcile()) + .catch(error => this.controller.report(error)); + } + }); + }); + this.server.maxConnections = 16; + await new Promise((resolve, reject) => { + this.server.once("error", reject); + this.server.listen(endpoint, resolve); + }); + this.server.on("error", error => this.controller.report(error)); + } + async handle(peer, method, params) { + if (method === "attach") { + if (this.feed && this.feed !== peer && !this.feed.closed) + throw new Error("Another extension is providing App status"); + this.feed = peer; + return this.status(); + } + if (method === "snapshot" || method === "unavailable") { + if (this.feed !== peer) throw new Error("Attach the App feed first"); + // Invalidate before validating: a malformed update must never preserve a live indication. + this.receivedAt = -Infinity; this.controller.stale = true; + if (method === "snapshot") { + try { + const sessions = parseSnapshot({ sessions: params?.sessions }); + if (!Array.isArray(params?.catalogue)) throw new Error("Invalid App catalogue"); + for (const id of this.controller.config.slots.filter(Boolean)) { + if (sessions.some(s => s.id === id)) sessionUrl(params.catalogue, id); + } + this.sessions = sessions; this.catalogue = params.catalogue; + this.receivedAt = this.now(); + } catch (error) { + this.catalogue = []; + throw error; + } + } + await this.controller.enqueue(() => this.reconcile()); + return this.status(); + } + if (method === "status") return this.status(); + if (method === "stop") { + try { await this.controller.stop(); } finally { this.release(); } + return this.status(); + } + if (method === "start") { + try { await this.prepare(); await this.controller.start(); } + catch (error) { this.release(); throw error; } + return this.status(); + } + if (method === "bind" || method === "configure") { + if (!this.fresh()) throw new Error("App status unavailable; configuration was not changed"); + const ids = method === "bind" ? [params?.sessionId] : params?.slots; + if (Array.isArray(ids)) { + for (const id of ids.filter(id => id !== null)) sessionUrl(this.catalogue, id); + } + if (method === "bind") await this.controller.bind(params?.slot, params?.sessionId); + else await this.controller.configure(params); + return this.status(); + } + throw new Error(`Unsupported bridge operation: ${method}`); + } + async close() { + await this.controls; + try { await this.controller.stop(); } + finally { + this.release(); + for (const peer of this.peers) peer.close(); + if (this.server?.listening) await new Promise(resolve => this.server.close(resolve)); + } + } +} diff --git a/.github/extensions/ai-micro-copilot/controller.mjs b/.github/extensions/ai-micro-copilot/controller.mjs index d9859cd..9823aa0 100644 --- a/.github/extensions/ai-micro-copilot/controller.mjs +++ b/.github/extensions/ai-micro-copilot/controller.mjs @@ -2,10 +2,12 @@ import { eventAction, lightsFor, nextSlot, sessionState, validateConfig } from "./core.mjs"; export class Controller { - constructor({ config, readSessions, call, navigate, open, save, log, now = () => performance.now() }) { + constructor({ config, readSessions, call, navigate, open, save, log, now = () => performance.now(), + offlineNavigation = () => false }) { this.config = validateConfig(config); this.readSessions = readSessions; this.call = call; this.navigate = navigate; + this.offlineNavigation = offlineNavigation; this.open = open; this.save = save; this.log = log; this.sessions = []; this.selected = null; this.running = false; this.stale = true; this.device = null; this.fault = null; @@ -207,8 +209,15 @@ export class Controller { catch (error) { this.fault = error; throw error; } if (action === "none") return; const cachedNavigation = ["slot", "next", "previous", "focus"].includes(action); - if (!cachedNavigation || this.stale || this.now() - this.refreshedAt >= 2500) - await this.refresh(); + if (!cachedNavigation || this.stale || this.now() - this.refreshedAt >= 2500 || + this.offlineNavigation()) { + try { await this.refresh(); } + catch (error) { + if (!cachedNavigation || !this.offlineNavigation()) throw error; + this.report(error); + await this.paint(); + } + } if (!this.running) return; if (this.device !== device || this.fault) throw new Error("Device disconnected during status refresh; action was not executed"); diff --git a/.github/extensions/ai-micro-copilot/daemon.mjs b/.github/extensions/ai-micro-copilot/daemon.mjs new file mode 100644 index 0000000..dbb8034 --- /dev/null +++ b/.github/extensions/ai-micro-copilot/daemon.mjs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +import { readFile, writeFile, rename, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { createHash, randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { validateConfig, DEFAULT_CONFIG } from "./core.mjs"; +import { MicroHid } from "./hid.mjs"; +import { createUriOpener } from "./windows-uri.mjs"; +import { BridgeService } from "./bridge-service.mjs"; +import { connectBridge } from "./bridge-rpc.mjs"; + +if (process.platform !== "win32") throw new Error("The standalone bridge requires Windows"); +const configPath = fileURLToPath(new URL("./config.json", import.meta.url)); +const runtimePath = fileURLToPath(new URL("./.bridge-runtime.json", import.meta.url)); +if (process.argv[2] === "--stop") { + const peer = await connectBridge(JSON.parse(await readFile(runtimePath, "utf8"))); + try { console.log(JSON.stringify(await peer.request("stop"))); } + finally { peer.close(); } +} else { + if (process.argv.length > 2) throw new Error("Usage: node daemon.mjs [--stop]"); + let config; + try { config = validateConfig(JSON.parse(await readFile(configPath, "utf8"))); } + catch (error) { + if (error.code !== "ENOENT") throw error; + config = structuredClone(DEFAULT_CONFIG); + } + const token = randomBytes(32).toString("hex"); + const owner = createHash("sha256").update(homedir()).digest("hex").slice(0, 16); + // Shares the original extension's singleton lock; an old bridge must stop before migration. + const endpoint = `\\\\.\\pipe\\ai-micro-copilot-${owner}`; + const log = (message, level = "info") => + console.error(`${new Date().toISOString()} [AI Micro ${level}] ${message}`); + const opener = createUriOpener(error => service.controller.report(error)); + const service = new BridgeService({ + config, token, open: options => MicroHid.open(options), + navigate: url => opener.open(url), prepare: () => opener.start(), release: () => opener.close(), + save: async value => { + await writeFile(`${configPath}.tmp`, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + await rename(`${configPath}.tmp`, configPath); + }, + log, + }); + await service.listen(endpoint); + try { + await writeFile(`${runtimePath}.tmp`, JSON.stringify({ + protocol: 1, endpoint, token, pid: process.pid, configPath, + }) + "\n", { mode: 0o600 }); + await rename(`${runtimePath}.tmp`, runtimePath); + } catch (error) { await service.close(); throw error; } + let stopping = false; + const shutdown = async () => { + if (stopping) return; + stopping = true; + try { await service.close(); } + finally { await unlink(runtimePath); } + }; + for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + shutdown().then(() => process.exit(0), error => { + log(`Shutdown failed: ${error.message}`, "error"); process.exit(1); + }); + }); + } + log(`Independent bridge ready (PID ${process.pid}); waiting for explicit start`); +} diff --git a/.github/extensions/ai-micro-copilot/extension.mjs b/.github/extensions/ai-micro-copilot/extension.mjs index 664df14..b0a5cba 100644 --- a/.github/extensions/ai-micro-copilot/extension.mjs +++ b/.github/extensions/ai-micro-copilot/extension.mjs @@ -1,43 +1,12 @@ // SPDX-License-Identifier: MIT import { joinSession } from "@github/copilot-sdk/extension"; -import { readFile, writeFile, rename } from "node:fs/promises"; -import { createServer } from "node:net"; -import { createHash } from "node:crypto"; -import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; -import { callApp, readSessions, validateConfig, DEFAULT_CONFIG, ACTIONS } from "./core.mjs"; -import { Controller } from "./controller.mjs"; -import { listDevices, MicroHid } from "./hid.mjs"; -import { createNavigator } from "./navigation.mjs"; -import { createUriOpener } from "./windows-uri.mjs"; +import { readSessions, ACTIONS } from "./core.mjs"; +import { listDevices } from "./hid.mjs"; +import { BridgeClient } from "./bridge-client.mjs"; const configPath = fileURLToPath(new URL("./config.json", import.meta.url)); -let config; -try { config = validateConfig(JSON.parse(await readFile(configPath, "utf8"))); } -catch (error) { - if (error.code !== "ENOENT") throw error; - config = structuredClone(DEFAULT_CONFIG); -} -let controller; -let navigator; -const uriOpener = createUriOpener(error => controller.report(error)); -let lock; -async function releaseLock() { - if (!lock) return; - const server = lock; lock = null; - await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); -} -async function acquireLock() { - if (process.platform !== "win32") throw new Error("This release supports Windows HID only"); - const owner = createHash("sha256").update(homedir()).digest("hex").slice(0, 16); - const server = createServer(socket => socket.destroy()); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(`\\\\.\\pipe\\ai-micro-copilot-${owner}`, resolve); - }); - server.on("error", error => controller.report(error)); - lock = server; -} +let client; const properties = { operation: { type: "string", enum: ["sessions", "devices", "configure", "bind", "start", "stop", "status"] }, slot: { type: "integer", minimum: 1, maximum: 6, description: "Agent key number for bind (1-based)." }, @@ -61,7 +30,7 @@ const session = await joinSession({ tools: [ { name: "ai_micro_control", - description: "Manage the Windows USB/Bluetooth AI Micro bridge without flashing firmware. List sessions/devices; bind one Agent key (slot 1-6, sessionId or null) without changing other keys, or configure all six slots. Binding saves config.json and applies live. Select transport and device explicitly; stop before changing connection settings. Start only after user selects bindings. Does not approve prompts or send agent messages.", + description: "Manage the independent Windows AI Micro USB/Bluetooth bridge. The daemon must be launched separately with node daemon.mjs. Session-host shutdown disconnects the status feed, not the keyboard: verified cached navigation remains available and LEDs become unknown. Bind one key (slot 1-6), configure, start, stop or inspect status. Preserve other bindings; stop before changing transport/device. Start only with user-selected bindings. No firmware flashing, permission approval or agent messages.", parameters: { type: "object", properties, required: ["operation"], additionalProperties: false }, handler: async ({ operation, config: update, slot, sessionId }) => { try { @@ -70,25 +39,13 @@ const session = await joinSession({ else if (operation === "devices") result = (await listDevices()) .map(d => ({ transport: d.transport, serialNumber: d.serialNumber, product: d.product, path: d.path })); else if (operation === "bind") { - try { result = await controller.bind(slot, sessionId); } - finally { navigator.clear(); } + result = await client.request("bind", { slot, sessionId }); } else if (operation === "configure") { if (!update) throw new Error("configure requires config"); - try { result = await controller.configure(update); } - finally { navigator.clear(); } - } else if (operation === "start") { - if (!controller.running) { - await acquireLock(); - try { await uriOpener.start(); await controller.start(); } - catch (error) { uriOpener.close(); await releaseLock(); throw error; } - } - result = controller.status(); - } else if (operation === "stop") { - try { await controller.stop(); } - finally { uriOpener.close(); navigator.clear(); await releaseLock(); } - result = controller.status(); - } else if (operation === "status") result = controller.status(); + result = await client.request("configure", update); + } else if (["start", "stop", "status"].includes(operation)) + result = await client.request(operation); else throw new Error("Unknown operation"); return { resultType: "success", textResultForLlm: JSON.stringify(result) }; } catch (error) { @@ -98,44 +55,16 @@ const session = await joinSession({ }, ], }); -navigator = createNavigator(session, url => uriOpener.open(url)); -controller = new Controller({ - config, readSessions: async () => { - try { - const sessions = await readSessions(session); - await navigator.refresh(); - return sessions; - } catch (error) { - navigator.clear(); - throw error; - } - }, - call: (name, args) => callApp(session, name, args), - navigate: id => navigator.navigate(id), - open: options => MicroHid.open(options), - save: async value => { - await writeFile(`${configPath}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); - await rename(`${configPath}.tmp`, configPath); - }, +client = new BridgeClient({ + session, configPath, + runtimePath: fileURLToPath(new URL("./.bridge-runtime.json", import.meta.url)), log: (message, level) => { session.log(`[AI Micro] ${message}`, { level }).catch(error => console.error(`[AI Micro] ${message}; timeline logging failed: ${error.message}`)); }, }); -let stopping = false; -async function shutdown() { - if (stopping) return; - stopping = true; - try { await controller.stop(); } - catch (error) { console.error(`[AI Micro] Shutdown: ${error.message}`); } - finally { - uriOpener.close(); - navigator.clear(); - try { await releaseLock(); } - catch (error) { console.error(`[AI Micro] Lock cleanup: ${error.message}`); } - } -} -session.on("session.shutdown", () => { void shutdown(); }); +client.start(); +session.on("session.shutdown", () => client.close()); for (const signal of ["SIGINT", "SIGTERM"]) { - process.once(signal, () => { shutdown().finally(() => process.exit(0)); }); + process.once(signal, () => { client.close(); process.exit(0); }); } diff --git a/.github/extensions/ai-micro-copilot/package.json b/.github/extensions/ai-micro-copilot/package.json index 898974a..37a2d99 100644 --- a/.github/extensions/ai-micro-copilot/package.json +++ b/.github/extensions/ai-micro-copilot/package.json @@ -7,7 +7,8 @@ "type": "module", "engines": { "node": ">=22" }, "scripts": { - "check": "node --check extension.mjs && node --check controller.mjs && node --check core.mjs && node --check hid.mjs && node --check navigation.mjs && node --check windows-uri.mjs", + "check": "node --check extension.mjs && node --check controller.mjs && node --check core.mjs && node --check hid.mjs && node --check navigation.mjs && node --check windows-uri.mjs && node --check bridge-rpc.mjs && node --check bridge-service.mjs && node --check bridge-client.mjs && node --check daemon.mjs", + "daemon": "node daemon.mjs", "test": "node --test tests/*.test.mjs" }, "dependencies": { "node-hid": "^3.1.0" } diff --git a/.github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs b/.github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs new file mode 100644 index 0000000..9b7d057 --- /dev/null +++ b/.github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +import test from "node:test"; +import assert from "node:assert/strict"; +import { fork } from "node:child_process"; +import { once } from "node:events"; +import { randomBytes } from "node:crypto"; +import { mkdtemp, writeFile, unlink, rmdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { BridgeClient } from "../bridge-client.mjs"; + +test("A newly loaded extension automatically resumes status without restarting HID or undoing explicit stop", { + skip: process.platform !== "win32", timeout: 20000, +}, async () => { + const endpoint = `\\\\.\\pipe\\ai-micro-copilot-${randomBytes(8).toString("hex")}`; + const token = randomBytes(32).toString("hex"); + const directory = await mkdtemp(join(tmpdir(), "ai-micro-client-")); + const runtimePath = join(directory, ".bridge-runtime.json"), configPath = join(directory, "config.json"); + const child = fork(new URL("./fixtures/persistent-worker.mjs", import.meta.url), [endpoint, token], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + child.stderr.on("data", chunk => process.stderr.write(chunk)); + const id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const logs = [], clients = []; + let unavailable = false; + const session = { rpc: { tools: { execute: async ({ name }) => { + if (unavailable) throw new Error("App unavailable"); + assert(["get_sessions_status", "list_sessions_and_chats"].includes(name)); + return { resultType: "success", structuredContent: name === "get_sessions_status" + ? { sessions: [{ id, activity: { status: "idle" } }] } : [{ id, app_url: `ghapp://sessions/${id}` }] }; + } } } }; + const makeClient = () => { + const client = new BridgeClient({ session, runtimePath, configPath, + log: message => logs.push(message) }); + clients.push(client); + return client; + }; + try { + await once(child, "message"); + await writeFile(runtimePath, JSON.stringify({ protocol: 1, endpoint, token, configPath })); + const first = makeClient(); + assert.equal((await first.request("start")).running, true); + first.close(); + const second = makeClient(); + second.start(); + await second.queue; + const recovered = await second.request("status"); + assert.equal(recovered.running, true); + assert.equal(recovered.appConnected, true); + assert.equal(recovered.stale, false); + unavailable = true; + await assert.rejects(second.request("configure", { brightness: 0.5 }), /App unavailable/); + assert.equal((await second.request("status")).stale, true); + await second.request("stop"); + second.close(); + unavailable = false; + const third = makeClient(); + third.start(); + await third.queue; + assert.equal((await third.request("status")).running, false); + assert.deepEqual(logs, []); + } finally { + for (const client of clients) client.close(); + child.kill(); await once(child, "exit"); + await unlink(runtimePath); + await rmdir(directory); + } +}); diff --git a/.github/extensions/ai-micro-copilot/tests/fixtures/feed-worker.mjs b/.github/extensions/ai-micro-copilot/tests/fixtures/feed-worker.mjs new file mode 100644 index 0000000..b01fd12 --- /dev/null +++ b/.github/extensions/ai-micro-copilot/tests/fixtures/feed-worker.mjs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +import { connectBridge } from "../../bridge-rpc.mjs"; +const peer = await connectBridge({ endpoint: process.argv[2], token: process.argv[3] }); +const id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +await peer.request("attach"); +await peer.request("snapshot", { + sessions: [{ id, name: "Bound session", activity: { status: "idle" } }], + catalogue: [{ id, app_url: `ghapp://sessions/${id}` }], +}); +process.send(await peer.request("start")); diff --git a/.github/extensions/ai-micro-copilot/tests/fixtures/persistent-worker.mjs b/.github/extensions/ai-micro-copilot/tests/fixtures/persistent-worker.mjs new file mode 100644 index 0000000..169dcd1 --- /dev/null +++ b/.github/extensions/ai-micro-copilot/tests/fixtures/persistent-worker.mjs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +import { EventEmitter } from "node:events"; +import { BridgeService } from "../../bridge-service.mjs"; +const device = new EventEmitter(); +let lights = [], closed = false; +const opened = []; +let clock = 0; +device.info = { serialNumber: "test-usb" }; +device.request = async (method, params) => { + if (method === "v.oai.thstatus") lights = params; +}; +device.close = async () => { closed = true; }; +const service = new BridgeService({ + config: { slots: ["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", null, null, null, null, null] }, + token: process.argv[3], + open: async () => device, + navigate: async url => { opened.push(url); }, + save: async () => {}, + log: () => {}, + now: () => clock, +}); +await service.listen(process.argv[2]); +process.on("message", async message => { + if (message.advance) clock += message.advance; + await service.controller.enqueue(() => service.controller.tick()); + if (message.input) device.emit("input", { method: "v.oai.hid", params: { k: "AG00", ag: 0, act: 1 } }); + await service.controller.queue; + process.send({ opened, lights, closed, status: service.status() }); +}); +process.send({ ready: true }); diff --git a/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs b/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs new file mode 100644 index 0000000..b6bf5a4 --- /dev/null +++ b/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +import test from "node:test"; +import assert from "node:assert/strict"; +import { fork } from "node:child_process"; +import { once } from "node:events"; +import { randomUUID } from "node:crypto"; +import { connectBridge } from "../bridge-rpc.mjs"; + +const id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const url = `ghapp://sessions/${id}`; +const snapshot = { + sessions: [{ id, name: "Bound session", activity: { status: "idle" } }], + catalogue: [{ id, app_url: url }], +}; + +test("Separate HID process survives feed loss, navigates offline, restores state and honors explicit stop", { + skip: process.platform !== "win32", timeout: 20000, +}, async () => { + const endpoint = `\\\\.\\pipe\\ai-micro-test-${randomUUID()}`; + const token = randomUUID(); + const child = fork(new URL("./fixtures/persistent-worker.mjs", import.meta.url), [endpoint, token], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + let diagnostics = ""; + child.stderr.on("data", chunk => { diagnostics += chunk; }); + let peer, producer; + const inspectInput = async () => { + const reply = once(child, "message"); + child.send({ input: true }); + return (await reply)[0]; + }; + try { + const [ready] = await once(child, "message"); + assert.equal(ready.ready, true, diagnostics); + producer = fork(new URL("./fixtures/feed-worker.mjs", import.meta.url), [endpoint, token], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + producer.stderr.on("data", chunk => { diagnostics += chunk; }); + const [started] = await once(producer, "message"); + assert.equal(started.connected, true); + assert.equal(started.stale, false); + const exited = once(producer, "exit"); + producer.kill(); + await exited; + peer = await connectBridge({ endpoint, token }); + const offline = await inspectInput(); + assert.deepEqual(offline.opened, [url]); + assert.equal(offline.status.running, true); + assert.equal(offline.status.stale, true); + assert.equal(offline.status.slots[0].state, "unknown"); + assert.equal(offline.lights[0].c, 0x8050ff); + assert.equal(offline.closed, false); + await assert.rejects(peer.request("configure", { brightness: 0.5 }), /App status unavailable/); + + await peer.request("attach"); + await peer.request("snapshot", snapshot); + const recovered = await inspectInput(); + assert.equal(recovered.status.stale, false); + assert.equal(recovered.lights[0].c, 0x00cc66); + assert.deepEqual(recovered.opened, [url, url]); + await peer.request("stop"); + await peer.request("snapshot", snapshot); + const stopped = await inspectInput(); + assert.equal(stopped.status.running, false); + assert.equal(stopped.closed, true); + assert.equal(stopped.opened.length, 2); + } finally { + peer?.close(); + child.kill(); + await once(child, "exit"); + } +}); + +test("Feed expiry and invalid updates cannot leave live LEDs or bypass URL validation", { + skip: process.platform !== "win32", timeout: 20000, +}, async () => { + const endpoint = `\\\\.\\pipe\\ai-micro-test-${randomUUID()}`, token = randomUUID(); + const child = fork(new URL("./fixtures/persistent-worker.mjs", import.meta.url), [endpoint, token], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + + child.stderr.on("data", chunk => process.stderr.write(chunk)); + let peer, second; + const inspect = async message => { + const reply = once(child, "message"); child.send(message); return (await reply)[0]; + }; + try { + await once(child, "message"); + await assert.rejects(connectBridge({ endpoint, token: "wrong" }), /authentication/); + peer = await connectBridge({ endpoint, token }); + await peer.request("attach"); + await peer.request("snapshot", snapshot); + await peer.request("start"); + second = await connectBridge({ endpoint, token }); + await assert.rejects(second.request("attach"), /Another extension/); + await assert.rejects(second.request("snapshot", snapshot), /Attach/); + const expired = await inspect({ advance: 5000, input: true }); + assert.equal(expired.status.appConnected, false); + assert.equal(expired.status.stale, true); + assert.deepEqual(expired.opened, [url]); + assert.equal(expired.lights[0].c, 0x8050ff); + await peer.request("snapshot", snapshot); + await assert.rejects(peer.request("snapshot", { + ...snapshot, catalogue: [{ id, app_url: `${url}?bad=1` }], + }), /invalid session URL/); + const invalid = await inspect({ input: true }); + assert.equal(invalid.opened.length, 1); + assert.equal(invalid.status.stale, true); + await peer.request("snapshot", snapshot); + await peer.request("snapshot", { sessions: [], catalogue: [] }); + const removed = await inspect({ input: true }); + assert.equal(removed.opened.length, 1); + assert.match(removed.status.error, /unavailable/); + await peer.request("stop"); + } finally { + peer?.close(); second?.close(); + child.kill(); await once(child, "exit"); + } +}); diff --git a/README.md b/README.md index 59fedb1..375a4cc 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This is an unofficial, community-maintained host extension, not firmware or an o - Bind each of the six Agent keys to a specific App project session, independent of sidebar ordering. - Show App-reported busy, waiting, idle, unknown and offline states on the keys. - Switch sessions directly using Agent keys, the rotary encoder and the joystick. +- Keep cached session navigation working after the App recycles the hosting CLI. A separate HID daemon survives; unavailable App status is shown as unknown, not as the last live color. - Change bindings through an agent conversation: save configuration and apply it immediately, without restarting the bridge. - Connect over USB or Windows Bluetooth HID, and retry the same device after disconnection. - Turn off background and function-key lighting after five minutes without hardware input, while keeping Agent status LEDs active; the next operation wakes the background and still performs its action. @@ -42,9 +43,10 @@ Other firmware versions are rejected until verified. This repository does not in npm ci --registry=https://registry.npmjs.org --no-audit --no-fund ``` -3. Ask the agent to reload extensions and confirm that `ai-micro-copilot` is ready. Do not install `@github/copilot-sdk` yourself or launch `extension.mjs` with `node`; the App provides the SDK and session connection. -4. Ask the agent to list AI Micro devices and available sessions. Select USB or Bluetooth, the corresponding device serial number, and at least one session binding. The public configuration example has **no personal IDs** and defaults to USB. -5. Ask the agent to start the AI Micro bridge and confirm its status is `running=true`, `connected=true`, `stale=false`, with no error. +3. From an **independent Windows terminal**, enter the same extension directory and run `npm run daemon`. Leave it running. This starts the hardware service but does not connect the keyboard until you request `start`. Do not launch it as an ordinary child of the recyclable Copilot CLI; an agent-assisted launch must use an explicitly detached process and verify readiness. +4. Ask the agent to reload extensions and confirm that `ai-micro-copilot` is ready. Do not install `@github/copilot-sdk` yourself or launch `extension.mjs` with `node`; the App provides the SDK and session connection. +5. Ask the agent to list AI Micro devices and available sessions. Select USB or Bluetooth, the corresponding device serial number, and at least one session binding. The public configuration example has **no personal IDs** and defaults to USB. +6. Ask the agent to start the AI Micro bridge and confirm its status is `running=true`, `connected=true`, `appConnected=true`, `stale=false`, with no error. Example conversation: @@ -70,9 +72,15 @@ Only one local session needs to host the bridge; the six target sessions do not The extension creates `.github\extensions\ai-micro-copilot\config.json` when you configure it. This ignored file contains your session bindings, device selection, brightness and key mappings. `config.example.json` documents the defaults; copying it is optional. -Use the `ai_micro_control` tool through the agent to change bindings. Its `bind` operation saves the file atomically and updates the running bridge. **There is no file watcher**: manual JSON edits require stopping, reloading and starting the extension. +Use the `ai_micro_control` tool through the agent to change bindings. Its `bind` operation saves the file atomically and updates the running bridge. **There is no file watcher**: manual JSON edits require stopping the bridge and restarting the daemon. -After restarting the computer or App, open the hosting session, let the extension load, and ask the agent to **start the bridge again**. Bindings persist, but the running state does not. An already-running bridge retries when the keyboard comes back online. Keep the hosting session running. +When the App recycles the hosting CLI, only its status feed disconnects. The independent daemon keeps the HID connection, device reconnect loop and verified navigation links. Bound keys become purple/unknown on feed loss (or after the freshness deadline if the feed stalls). Agent keys, previous/next and focus keep working from the last verified catalogue. Waiting-session selection, creation and configuration require fresh App data and fail explicitly while offline. Cached targets can have been deleted; the daemon never substitutes another session or invents a URL. + +When the hosting extension loads again, it reconnects and resumes status updates automatically, without reopening HID or requiring `start`. Pressing a key requests navigation, not a model turn, so navigating to a session does not guarantee the App reloads its extension immediately. Status remains unknown until a fresh feed is available. + +`stop` explicitly closes HID, turns off LEDs and stops navigation; feed reconnection does **not** undo it. The daemon remains available for management. With no App connection, run `node daemon.mjs --stop` from the same directory to stop the hardware. Ctrl+C in the independent daemon terminal exits the service. After a computer restart or daemon exit, launch it again and explicitly start the bridge; no Windows autostart is installed. An App restart alone need not terminate the daemon. + +The ignored `.bridge-runtime.json` contains a per-launch local IPC credential, never a GitHub token. Do not share it. One authenticated Windows named-pipe service owns the keyboard per Windows user; it accepts one status producer at a time. This is a local same-user trust boundary, not a sandbox against programs already running as you. `backgroundIdleMs` controls background-light inactivity: it defaults to `300000` (five minutes), accepts integer milliseconds up to `86400000`, and can be set to `0` to disable automatic background shutoff. Existing configurations without this field use the five-minute default. Agent LEDs keep updating; App activity and binding changes do not reset the hardware-input timer. Shutoff is applied on the first polling cycle after the threshold, not an exact hardware timer. Reconnection preserves inactivity, while restarting the bridge starts a fresh interval. Controls mapped to `none` can still wake the lights without triggering an App action. This saves LED power, not Bluetooth power, and does not enable firmware deep sleep. @@ -92,6 +100,10 @@ ai-micro-copilot-app\ extensions\ ai-micro-copilot\ extension.mjs + daemon.mjs + bridge-client.mjs + bridge-service.mjs + bridge-rpc.mjs controller.mjs core.mjs hid.mjs @@ -119,9 +131,11 @@ npm test Tests use mock HID and App adapters; they do not connect to a physical keyboard or require a running App. GitHub Actions runs syntax checks and tests on Windows with Node.js 22. Hardware acceptance is separate. -The entrypoint registers the tool and owns configuration persistence and the single-instance lock. `controller.mjs` coordinates session state and actions; `core.mjs` handles configuration, framing and state mapping; `hid.mjs` manages USB/Bluetooth HID; `navigation.mjs` validates App-supplied session links before opening them. +Lifecycle regressions run real separate Node processes and Windows named pipes, terminate the App-feed producer, and check continued navigation, unknown LEDs, feed restoration and explicit stop. USB hardware testing on 2026-10-02 also confirmed navigation after terminating the actual session extension and automatic status recovery after reloading it, without restarting the daemon or issuing another `start`. + +The entrypoint registers tools and feeds compact App state through `bridge-client.mjs`. `daemon.mjs` / `bridge-service.mjs` own configuration, the singleton pipe and HID lifetime; `bridge-rpc.mjs` provides authenticated, bounded request/reply transport. `controller.mjs` coordinates device state and actions; `core.mjs` handles configuration, framing and state mapping; `hid.mjs` manages USB/Bluetooth HID; `navigation.mjs` validates App-supplied links. -After changing a loaded extension, reload it and explicitly start the bridge again. Preserve the firmware identity checks, explicit device selection and prohibition on automatic approvals or prompt sending. +After changing daemon/controller code, explicitly stop hardware, restart the independent daemon, reload the extension and start hardware again. Reloading only the session extension does not update an already-running daemon. Preserve firmware identity checks, explicit device selection and the prohibition on automatic approvals or prompt sending. ## Compatibility and limitations @@ -130,10 +144,10 @@ Stock-firmware hardware testing has confirmed Bluetooth session navigation, LEDs - No Windows/App autostart, automatic USB/Bluetooth switching, or support claimed for macOS/Linux. - Sleep/wake, Bluetooth-adapter toggling, long-running stability, all ACT keys and six simultaneously active sessions still need hardware validation. - App tool and SDK compatibility is experimental and may change. -- Abrupt host exit may leave the last LED colors on the keyboard. LEDs are not a guaranteed live heartbeat. +- Loss of the App feed changes LEDs to unknown while the daemon is alive. A crash/forced termination of the daemon itself or computer sleep can still leave the last LED colors on the keyboard; LEDs are not a firmware-level heartbeat. - Error colors depend on what the App exposes; ordinary tool failures are not automatically classified as failed sessions. -When reporting an issue, include Windows, Node, App and firmware versions, transport, reproduction steps and a **redacted** error. Do not upload `config.json`, session IDs, device serial numbers, private session titles or full session logs. +When reporting an issue, include Windows, Node, App and firmware versions, transport, reproduction steps and a **redacted** error. Do not upload `config.json`, `.bridge-runtime.json`, session IDs, device serial numbers, private session titles or full session logs. ## License and acknowledgements From 5eb436713a73224ed7adcd0ab58cd3270e3e37a3 Mon Sep 17 00:00:00 2001 From: user3301 <26126682+user3301@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:49:10 +1100 Subject: [PATCH 2/4] Limit bridge snapshots to current and proposed bindings Keep the 1 MiB RPC guard while allowing large App catalogues, new bindings and regular status updates. Add a real named-pipe regression with over 12000 sessions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/ai-micro-copilot/README.md | 2 + .../ai-micro-copilot/bridge-client.mjs | 19 ++++-- .../tests/bridge-client.test.mjs | 65 +++++++++++++++++++ README.md | 2 + 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/.github/extensions/ai-micro-copilot/README.md b/.github/extensions/ai-micro-copilot/README.md index 64b9036..9f0dd83 100644 --- a/.github/extensions/ai-micro-copilot/README.md +++ b/.github/extensions/ai-micro-copilot/README.md @@ -88,6 +88,8 @@ npm test 没有 App 时可从同目录运行 `node daemon.mjs --stop` 停止硬件;独立终端 Ctrl+C 退出服务。`.bridge-runtime.json` 是忽略版本控制的本地凭据文件,禁止分享;不包含 GitHub Token。该接口仅用于本机可信用户进程,不是同用户恶意进程的安全沙箱。 +状态快照只发送当前已绑定和本次准备绑定的会话及链接,不发送整个 App 目录,避免大量无关会话撑大 IPC 消息,同时保留新绑定的验证。RPC 的单帧 1 MiB 限制保持不变。 + ### 通过对话动态绑定 在能调用此扩展工具的会话中说“把某某会话绑定到 Agent 2”。Agent 先从 `sessions` 解析真实 ID,再调用: diff --git a/.github/extensions/ai-micro-copilot/bridge-client.mjs b/.github/extensions/ai-micro-copilot/bridge-client.mjs index 23d3f38..8060a6a 100644 --- a/.github/extensions/ai-micro-copilot/bridge-client.mjs +++ b/.github/extensions/ai-micro-copilot/bridge-client.mjs @@ -42,8 +42,10 @@ export class BridgeClient { this.peer = peer; this.catalogue = null; this.expires = 0; return peer; } - async publish(peer, forceCatalogue = false) { - await peer.request("attach"); + async publish(peer, forceCatalogue = false, pendingIds = []) { + const status = await peer.request("attach"); + const needed = new Set([...status.slots.map(slot => slot.sessionId), ...pendingIds] + .filter(id => typeof id === "string")); try { const sessions = await readSessions(this.session); if (forceCatalogue || this.catalogue === null || performance.now() >= this.expires) { @@ -55,13 +57,15 @@ export class BridgeClient { this.expires = performance.now() + 30000; } // Do not send prompts, conversation bodies, or full pending-input objects to the daemon. - const compact = sessions.map(s => ({ + const compact = sessions.filter(s => needed.has(s.id)).map(s => ({ id: s.id, name: s.name, project_id: s.project_id, is_running: s.is_running, awaiting_user_input: Boolean(s.awaiting_user_input), awaiting_plan_approval: Boolean(s.awaiting_plan_approval), activity: { status: s.activity?.status }, })); - await peer.request("snapshot", { sessions: compact, catalogue: this.catalogue }); + await peer.request("snapshot", { + sessions: compact, catalogue: this.catalogue.filter(item => needed.has(item.id)), + }); } catch (error) { this.catalogue = null; if (!peer.closed) { @@ -74,7 +78,12 @@ export class BridgeClient { request(operation, params) { return this.serialize(async () => { const peer = await this.connect(); - if (["start", "bind", "configure"].includes(operation)) await this.publish(peer, true); + if (["start", "bind", "configure"].includes(operation)) { + // Include proposed targets before the daemon validates and saves new bindings. + const pendingIds = operation === "bind" ? [params?.sessionId] + : operation === "configure" && Array.isArray(params?.slots) ? params.slots : []; + await this.publish(peer, true, pendingIds); + } const result = await peer.request(operation, params); if (result?.pollMs) this.pollMs = result.pollMs; return result; diff --git a/.github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs b/.github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs index 9b7d057..601fae5 100644 --- a/.github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs +++ b/.github/extensions/ai-micro-copilot/tests/bridge-client.test.mjs @@ -9,6 +9,71 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { BridgeClient } from "../bridge-client.mjs"; +test("Large App catalogues support startup, polling and new bindings without exceeding RPC limits", { + skip: process.platform !== "win32", timeout: 20000, +}, async () => { + const endpoint = `\\\\.\\pipe\\ai-micro-copilot-${randomBytes(8).toString("hex")}`; + const token = randomBytes(32).toString("hex"); + const directory = await mkdtemp(join(tmpdir(), "ai-micro-large-client-")); + const runtimePath = join(directory, ".bridge-runtime.json"), configPath = join(directory, "config.json"); + const child = fork(new URL("./fixtures/persistent-worker.mjs", import.meta.url), [endpoint, token], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + child.stderr.on("data", chunk => process.stderr.write(chunk)); + const original = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const ids = [original, ...Array.from({ length: 12000 }, (_, i) => + `${i.toString(16).padStart(8, "0")}-bbbb-cccc-dddd-eeeeeeeeeeee`)]; + const sessions = ids.map(id => ({ id, name: `Session ${id}`, activity: { status: "idle" } })); + let catalogue = ids.map(id => ({ id, app_url: `ghapp://sessions/${id}` })); + assert(Buffer.byteLength(JSON.stringify(catalogue)) > 1024 * 1024); + assert(Buffer.byteLength(JSON.stringify(sessions)) > 1024 * 1024); + const session = { rpc: { tools: { execute: async ({ name }) => { + assert(["get_sessions_status", "list_sessions_and_chats"].includes(name)); + return { resultType: "success", structuredContent: name === "get_sessions_status" + ? { sessions } : catalogue }; + } } } }; + const logs = []; + const client = new BridgeClient({ session, runtimePath, configPath, log: message => logs.push(message) }); + const inspectInput = async () => { + const reply = once(child, "message"); + child.send({ input: true }); + return (await reply)[0]; + }; + try { + await once(child, "message"); + await writeFile(runtimePath, JSON.stringify({ protocol: 1, endpoint, token, configPath })); + assert.equal((await client.request("start")).appConnected, true); + assert.deepEqual((await inspectInput()).opened, [`ghapp://sessions/${original}`]); + const replacement = ids.at(-1), added = ids.at(-2); + const rebound = await client.request("bind", { slot: 1, sessionId: replacement }); + assert.equal(rebound.slots[0].sessionId, replacement); + const configured = await client.request("configure", { + slots: [replacement, added, null, null, null, null], + }); + assert.equal(configured.slots[1].sessionId, added); + client.start(); + await client.queue; + assert.equal((await client.request("status")).stale, false); + assert.deepEqual((await inspectInput()).opened, [ + `ghapp://sessions/${original}`, `ghapp://sessions/${replacement}`, + ]); + assert.equal((await client.request("bind", { slot: 2, sessionId: null })).slots[1].sessionId, null); + const invalid = ids.at(-3); + catalogue = catalogue.map(item => item.id === invalid ? { ...item, app_url: "https://example.com" } : item); + await assert.rejects(client.request("bind", { slot: 2, sessionId: invalid }), /invalid session URL/); + assert.equal((await client.request("status")).slots[1].sessionId, null); + await client.request("configure", { brightness: 0.5 }); + assert.equal((await client.request("status")).appConnected, true); + assert.deepEqual(logs, []); + await client.request("stop"); + } finally { + client.close(); + child.kill(); await once(child, "exit"); + await unlink(runtimePath); + await rmdir(directory); + } +}); + test("A newly loaded extension automatically resumes status without restarting HID or undoing explicit stop", { skip: process.platform !== "win32", timeout: 20000, }, async () => { diff --git a/README.md b/README.md index 375a4cc..b4a20d8 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ When the hosting extension loads again, it reconnects and resumes status updates The ignored `.bridge-runtime.json` contains a per-launch local IPC credential, never a GitHub token. Do not share it. One authenticated Windows named-pipe service owns the keyboard per Windows user; it accepts one status producer at a time. This is a local same-user trust boundary, not a sandbox against programs already running as you. +Status snapshots include only currently bound sessions and any proposed binding targets, not the entire App catalogue. This keeps large unrelated session lists out of the 1 MiB RPC frames while allowing new bindings to be validated. The frame-size limit remains enforced. + `backgroundIdleMs` controls background-light inactivity: it defaults to `300000` (five minutes), accepts integer milliseconds up to `86400000`, and can be set to `0` to disable automatic background shutoff. Existing configurations without this field use the five-minute default. Agent LEDs keep updating; App activity and binding changes do not reset the hardware-input timer. Shutoff is applied on the first polling cycle after the threshold, not an exact hardware timer. Reconnection preserves inactivity, while restarting the bridge starts a fresh interval. Controls mapped to `none` can still wake the lights without triggering an App action. This saves LED power, not Bluetooth power, and does not enable firmware deep sleep. The default ACT09 action creates an empty App session, potentially including a worktree. It does not start an agent or send a prompt. You can disable it with `config.keys.ACT09 = "none"` through `configure`. See the [full key map, LED meanings and tool reference](.github/extensions/ai-micro-copilot/README.md). From 4b1561f544c3349b9da44804e2de3f3dbb75f3b6 Mon Sep 17 00:00:00 2001 From: user3301 <26126682+user3301@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:28:06 +1100 Subject: [PATCH 3/4] Prevent control requests from racing bridge shutdown Seal pipe admission and close clients before draining controls and stopping HID. Cover late starts, in-flight starts and concurrent shutdown with real named-pipe regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ai-micro-copilot/bridge-service.mjs | 33 +++++-- .../tests/persistent.test.mjs | 95 ++++++++++++++++++- README.md | 2 + 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/.github/extensions/ai-micro-copilot/bridge-service.mjs b/.github/extensions/ai-micro-copilot/bridge-service.mjs index 6467730..005f688 100644 --- a/.github/extensions/ai-micro-copilot/bridge-service.mjs +++ b/.github/extensions/ai-micro-copilot/bridge-service.mjs @@ -15,6 +15,7 @@ export class BridgeService { this.prepare = prepare; this.release = release; this.peers = new Set(); this.controls = Promise.resolve(); + this.stopping = false; this.shutdown = null; this.controller = new Controller({ config, open, save, log, now, readSessions: async () => { @@ -46,8 +47,10 @@ export class BridgeService { } async listen(endpoint) { this.server = createServer(socket => { + if (this.stopping) { socket.destroy(); return; } let authenticated = false; const peer = new BridgePeer(socket, async (method, params) => { + if (this.stopping) throw new Error("Bridge service is shutting down"); if (!authenticated) { const supplied = Buffer.from(typeof params?.token === "string" ? params.token : ""); const expected = Buffer.from(this.token); @@ -70,8 +73,9 @@ export class BridgeService { if (this.feed === peer) { this.feed = null; this.receivedAt = -Infinity; this.controller.stale = true; - this.controller.enqueue(() => this.reconcile()) - .catch(error => this.controller.report(error)); + if (!this.stopping) + this.controller.enqueue(() => this.reconcile()) + .catch(error => this.controller.report(error)); } }); }); @@ -83,6 +87,7 @@ export class BridgeService { this.server.on("error", error => this.controller.report(error)); } async handle(peer, method, params) { + if (this.stopping) throw new Error("Bridge service is shutting down"); if (method === "attach") { if (this.feed && this.feed !== peer && !this.feed.closed) throw new Error("Another extension is providing App status"); @@ -132,13 +137,21 @@ export class BridgeService { } throw new Error(`Unsupported bridge operation: ${method}`); } - async close() { - await this.controls; - try { await this.controller.stop(); } - finally { - this.release(); - for (const peer of this.peers) peer.close(); - if (this.server?.listening) await new Promise(resolve => this.server.close(resolve)); - } + close() { + if (this.shutdown) return this.shutdown; + this.stopping = true; + // Seal admission before capturing the final control chain, including buffered requests. + const serverClosed = this.server?.listening + ? new Promise(resolve => this.server.close(resolve)) : Promise.resolve(); + for (const peer of this.peers) peer.close(); + this.shutdown = (async () => { + await this.controls; + try { await this.controller.stop(); } + finally { + try { this.release(); } + finally { await serverClosed; } + } + })(); + return this.shutdown; } } diff --git a/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs b/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs index b6bf5a4..69eb686 100644 --- a/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs +++ b/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs @@ -2,9 +2,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { fork } from "node:child_process"; -import { once } from "node:events"; +import { EventEmitter, once } from "node:events"; import { randomUUID } from "node:crypto"; import { connectBridge } from "../bridge-rpc.mjs"; +import { BridgeService } from "../bridge-service.mjs"; const id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; const url = `ghapp://sessions/${id}`; @@ -13,6 +14,98 @@ const snapshot = { catalogue: [{ id, app_url: url }], }; +test("Service shutdown rejects new starts while HID close is pending", { + skip: process.platform !== "win32", timeout: 20000, +}, async () => { + const endpoint = `\\\\.\\pipe\\ai-micro-test-${randomUUID()}`, token = randomUUID(); + let closingDevice, finishDeviceClose, opens = 0, prepares = 0; + const deviceClosing = new Promise(resolve => { closingDevice = resolve; }); + const deviceCloseGate = new Promise(resolve => { finishDeviceClose = resolve; }); + const device = new EventEmitter(); + device.request = async () => {}; + device.close = async () => { closingDevice(); await deviceCloseGate; }; + const service = new BridgeService({ + config: { slots: [id, null, null, null, null, null] }, token, + open: async () => { opens++; return device; }, + prepare: async () => { prepares++; }, + navigate: async () => {}, save: async () => {}, log: () => {}, + }); + let peer, shutdown; + try { + await service.listen(endpoint); + peer = await connectBridge({ endpoint, token }); + await peer.request("attach"); + await peer.request("snapshot", snapshot); + await peer.request("start"); + shutdown = service.close(); + await deviceClosing; + const lateStart = peer.request("start").then( + () => "accepted", error => error.message); + // The status request is a wire-order barrier for the preceding start. + await peer.request("status").catch(() => {}); + finishDeviceClose(); + await shutdown; + assert.match(await lateStart, /closed|shutting down|EPIPE|ECONNRESET/i); + assert.equal(prepares, 1, "Shutdown must not launch another URI helper"); + assert.equal(opens, 1, "Shutdown must not reopen HID"); + assert.equal(service.status().running, false); + assert.equal(service.status().connected, false); + await assert.rejects(connectBridge({ endpoint, token })); + } finally { + finishDeviceClose(); + peer?.close(); + await shutdown; + await service.close(); + } +}); + +test("Service shutdown drains an in-flight start and concurrent closes share final cleanup", { + skip: process.platform !== "win32", timeout: 20000, +}, async () => { + const endpoint = `\\\\.\\pipe\\ai-micro-test-${randomUUID()}`, token = randomUUID(); + let enteringOpen, finishOpen, deviceCloses = 0, releases = 0; + const opening = new Promise(resolve => { enteringOpen = resolve; }); + const openGate = new Promise(resolve => { finishOpen = resolve; }); + const device = new EventEmitter(); + device.request = async () => {}; + device.close = async () => { deviceCloses++; }; + const service = new BridgeService({ + config: { slots: [id, null, null, null, null, null] }, token, + open: async () => { enteringOpen(); await openGate; return device; }, + release: () => { releases++; }, + navigate: async () => {}, save: async () => {}, log: () => {}, + }); + let peer, shutdown, start; + try { + await service.listen(endpoint); + peer = await connectBridge({ endpoint, token }); + await peer.request("attach"); + await peer.request("snapshot", snapshot); + start = peer.request("start").then(() => "accepted", error => error.message); + await opening; + shutdown = service.close(); + assert.equal(service.close(), shutdown); + let complete = false; + shutdown.then(() => { complete = true; }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(complete, false, "Cleanup must wait for an already running start"); + assert.equal(releases, 0); + finishOpen(); + await shutdown; + await start; + assert.equal(deviceCloses, 1); + assert.equal(releases, 1); + assert.equal(service.status().running, false); + assert.equal(service.status().connected, false); + } finally { + finishOpen(); + peer?.close(); + await start; + await shutdown; + await service.close(); + } +}); + test("Separate HID process survives feed loss, navigates offline, restores state and honors explicit stop", { skip: process.platform !== "win32", timeout: 20000, }, async () => { diff --git a/README.md b/README.md index b4a20d8..31b4e9b 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ When the hosting extension loads again, it reconnects and resumes status updates The ignored `.bridge-runtime.json` contains a per-launch local IPC credential, never a GitHub token. Do not share it. One authenticated Windows named-pipe service owns the keyboard per Windows user; it accepts one status producer at a time. This is a local same-user trust boundary, not a sandbox against programs already running as you. +Daemon shutdown stops accepting requests and disconnects clients before draining in-flight controls and closing HID. Requests arriving during shutdown cannot restart the bridge. + Status snapshots include only currently bound sessions and any proposed binding targets, not the entire App catalogue. This keeps large unrelated session lists out of the 1 MiB RPC frames while allowing new bindings to be validated. The frame-size limit remains enforced. `backgroundIdleMs` controls background-light inactivity: it defaults to `300000` (five minutes), accepts integer milliseconds up to `86400000`, and can be set to `0` to disable automatic background shutoff. Existing configurations without this field use the five-minute default. Agent LEDs keep updating; App activity and binding changes do not reset the hardware-input timer. Shutoff is applied on the first polling cycle after the threshold, not an exact hardware timer. Reconnection preserves inactivity, while restarting the bridge starts a fresh interval. Controls mapped to `none` can still wake the lights without triggering an App action. This saves LED power, not Bluetooth power, and does not enable firmware deep sleep. From 9f83df08beb666170941117f56e20b4bffbd1a27 Mon Sep 17 00:00:00 2001 From: user3301 <26126682+user3301@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:40:17 +1100 Subject: [PATCH 4/4] Retain bridge singleton through shutdown cleanup Keep rejecting traffic while draining HID and removing discovery, then release the named pipe. Verify blocked replacement claims and safe runtime handoff with real Windows pipes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ai-micro-copilot/bridge-service.mjs | 14 ++-- .../extensions/ai-micro-copilot/daemon.mjs | 3 +- .../tests/persistent.test.mjs | 71 +++++++++++++++++++ README.md | 2 +- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/.github/extensions/ai-micro-copilot/bridge-service.mjs b/.github/extensions/ai-micro-copilot/bridge-service.mjs index 005f688..93e1238 100644 --- a/.github/extensions/ai-micro-copilot/bridge-service.mjs +++ b/.github/extensions/ai-micro-copilot/bridge-service.mjs @@ -137,19 +137,23 @@ export class BridgeService { } throw new Error(`Unsupported bridge operation: ${method}`); } - close() { + close(beforeUnlock = async () => {}) { if (this.shutdown) return this.shutdown; this.stopping = true; - // Seal admission before capturing the final control chain, including buffered requests. - const serverClosed = this.server?.listening - ? new Promise(resolve => this.server.close(resolve)) : Promise.resolve(); + // Reject traffic but retain the singleton pipe until hardware and discovery cleanup finish. for (const peer of this.peers) peer.close(); this.shutdown = (async () => { await this.controls; try { await this.controller.stop(); } finally { try { this.release(); } - finally { await serverClosed; } + finally { + try { await beforeUnlock(); } + finally { + if (this.server?.listening) + await new Promise(resolve => this.server.close(resolve)); + } + } } })(); return this.shutdown; diff --git a/.github/extensions/ai-micro-copilot/daemon.mjs b/.github/extensions/ai-micro-copilot/daemon.mjs index dbb8034..1c82771 100644 --- a/.github/extensions/ai-micro-copilot/daemon.mjs +++ b/.github/extensions/ai-micro-copilot/daemon.mjs @@ -51,8 +51,7 @@ if (process.argv[2] === "--stop") { const shutdown = async () => { if (stopping) return; stopping = true; - try { await service.close(); } - finally { await unlink(runtimePath); } + await service.close(() => unlink(runtimePath)); }; for (const signal of ["SIGINT", "SIGTERM"]) { process.once(signal, () => { diff --git a/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs b/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs index 69eb686..dd628c8 100644 --- a/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs +++ b/.github/extensions/ai-micro-copilot/tests/persistent.test.mjs @@ -4,6 +4,10 @@ import assert from "node:assert/strict"; import { fork } from "node:child_process"; import { EventEmitter, once } from "node:events"; import { randomUUID } from "node:crypto"; +import { createServer } from "node:net"; +import { mkdtemp, readFile, writeFile, unlink, rmdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { connectBridge } from "../bridge-rpc.mjs"; import { BridgeService } from "../bridge-service.mjs"; @@ -14,6 +18,73 @@ const snapshot = { catalogue: [{ id, app_url: url }], }; +test("Shutdown retains the singleton until HID and discovery cleanup finish", { + skip: process.platform !== "win32", timeout: 20000, +}, async () => { + const endpoint = `\\\\.\\pipe\\ai-micro-test-${randomUUID()}`, token = randomUUID(); + const directory = await mkdtemp(join(tmpdir(), "ai-micro-shutdown-")); + const runtimePath = join(directory, ".bridge-runtime.json"); + let enteringClose, finishClose, enteringCleanup, finishCleanup; + const closing = new Promise(resolve => { enteringClose = resolve; }); + const closeGate = new Promise(resolve => { finishClose = resolve; }); + const cleaning = new Promise(resolve => { enteringCleanup = resolve; }); + const cleanupGate = new Promise(resolve => { finishCleanup = resolve; }); + const device = new EventEmitter(); + device.request = async () => {}; + device.close = async () => { enteringClose(); await closeGate; }; + const service = new BridgeService({ + config: { slots: [id, null, null, null, null, null] }, token, + open: async () => device, navigate: async () => {}, save: async () => {}, log: () => {}, + }); + const contenders = []; + const claim = async () => { + const server = createServer(socket => socket.destroy()); + contenders.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(endpoint, resolve); + }); + }; + let peer, shutdown; + try { + await service.listen(endpoint); + await writeFile(runtimePath, "old runtime"); + peer = await connectBridge({ endpoint, token }); + await peer.request("attach"); + await peer.request("snapshot", snapshot); + await peer.request("start"); + shutdown = service.close(async () => { + enteringCleanup(); + await cleanupGate; + await unlink(runtimePath); + }); + await closing; + await assert.rejects(claim(), { code: "EADDRINUSE" }); + await assert.rejects(connectBridge({ endpoint, token })); + assert.equal(await readFile(runtimePath, "utf8"), "old runtime"); + finishClose(); + await cleaning; + await assert.rejects(claim(), { code: "EADDRINUSE" }); + finishCleanup(); + await shutdown; + await assert.rejects(readFile(runtimePath), { code: "ENOENT" }); + await claim(); + await writeFile(runtimePath, "replacement runtime"); + await service.close(); + assert.equal(await readFile(runtimePath, "utf8"), "replacement runtime"); + } finally { + finishClose(); finishCleanup(); + peer?.close(); + await shutdown; + await service.close(); + for (const server of contenders) { + if (server.listening) await new Promise(resolve => server.close(resolve)); + } + await unlink(runtimePath).catch(error => { if (error.code !== "ENOENT") throw error; }); + await rmdir(directory); + } +}); + test("Service shutdown rejects new starts while HID close is pending", { skip: process.platform !== "win32", timeout: 20000, }, async () => { diff --git a/README.md b/README.md index 31b4e9b..e5ceb33 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ When the hosting extension loads again, it reconnects and resumes status updates The ignored `.bridge-runtime.json` contains a per-launch local IPC credential, never a GitHub token. Do not share it. One authenticated Windows named-pipe service owns the keyboard per Windows user; it accepts one status producer at a time. This is a local same-user trust boundary, not a sandbox against programs already running as you. -Daemon shutdown stops accepting requests and disconnects clients before draining in-flight controls and closing HID. Requests arriving during shutdown cannot restart the bridge. +Daemon shutdown rejects requests and disconnects clients before draining in-flight controls and closing HID. It retains the singleton pipe until hardware cleanup and discovery-file removal finish, then releases it for a replacement daemon. Requests arriving during shutdown cannot restart the bridge. Status snapshots include only currently bound sessions and any proposed binding targets, not the entire App catalogue. This keeps large unrelated session lists out of the 1 MiB RPC frames while allowing new bindings to be validated. The frame-size limit remains enforced.