Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/extensions/ai-micro-copilot/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules/
config.json
.bridge-runtime.json
.bridge-runtime.json.tmp
config.json.tmp
26 changes: 18 additions & 8 deletions .github/extensions/ai-micro-copilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 处理器
Expand Down Expand Up @@ -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 协议;不要为安装本扩展而擦除或替换固件。

## 配置和启动
Expand All @@ -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 的顺序;不能擅自取列表前六项。示例:

Expand Down Expand Up @@ -80,7 +82,13 @@ 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。该接口仅用于本机可信用户进程,不是同用户恶意进程的安全沙箱。

状态快照只发送当前已绑定和本次准备绑定的会话及链接,不发送整个 App 目录,避免大量无关会话撑大 IPC 消息,同时保留新绑定的验证。RPC 的单帧 1 MiB 限制保持不变。

### 通过对话动态绑定

Expand All @@ -96,7 +104,7 @@ npm test

取消绑定使用 `"sessionId": null`。工具串行校验、原子保存 `config.json`,然后同步内存配置和灯光;桥接运行中不需要重启,之后按键使用新绑定。重复 ID、无效槽位、不存在的会话及写盘失败会明确报错,不覆盖当前绑定。已保存但灯光同步失败时会明确提示“配置已保存”,并在恢复连接后重新同步。

**没有文件监听器**:Agent 应调用工具,而不是直接改 JSON 文件。手工编辑磁盘文件需先停止并重新加载扩展,再启动才生效。工具只出现在加载了此扩展的会话中;不保证所有项目会话都直接具备它。
**没有文件监听器**:Agent 应调用工具,而不是直接改 JSON 文件。手工编辑磁盘文件需先停止硬件并重启独立服务,再启动才生效。工具只出现在加载了此扩展的会话中;不保证所有项目会话都直接具备它。

## 按键与灯光

Expand All @@ -118,9 +126,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"}}}`。

Expand Down Expand Up @@ -158,6 +166,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 个旋转事件及摇杆四方向。
Expand All @@ -178,4 +188,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,本版不声称解决这一限制。
118 changes: 118 additions & 0 deletions .github/extensions/ai-micro-copilot/bridge-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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, 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) {
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.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.filter(item => needed.has(item.id)),
});
} 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)) {
// 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;
});
}
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();
}
}
Loading
Loading