@coda-console/grok-bot 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -50
- package/agent/auth.mjs +88 -0
- package/agent/health.mjs +23 -0
- package/agent/native.mjs +73 -0
- package/agent/routines.mjs +104 -0
- package/agent/session.mjs +292 -78
- package/agent/setup.mjs +4 -3
- package/agent/watch.mjs +143 -0
- package/package.json +28 -27
- package/remote/SKILL.md +12 -22
- package/remote/coda-bot.mjs +5 -13
package/README.md
CHANGED
|
@@ -1,76 +1,66 @@
|
|
|
1
1
|
# Grok Bot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 xAI 云端 Grok Bot 的**原生主会话**接到 Coda。Coda 消息原样发送;回复、运行状态、活动摘要和选择卡片自动镜像,不需要 Bot 调用专门的汇报工具。定时任务仍由 Grok Routine 执行。与 Grok CLI 插件 `grok` 无关,本插件与 Agent ID 均为 `grok-bot`。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装与登录
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
1. 在 Coda 安装 Grok Bot,在空目录(例如 Bot)新建 Grok Bot 会话。
|
|
8
|
+
2. 发送 `/login`,打开会话中的官方登录链接完成账号登录。手机可能停在 **Completing sign-in**,请再点 **Continue**。适配器会轮询并更新 Bot 列表。
|
|
9
|
+
3. 在「这是哪个 Bot」按名称或 ID 选择。一个 Bot 对应一个 Coda 会话,避免重复镜像。
|
|
10
|
+
4. 正常发送消息。Grok 客户端里的主会话消息,以及 Routine 结束后主 Agent 自然发送的结论,都会作为 Coda 自主轮次呈现。
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
创建一个名为「Bot」的空目录,添加到 Coda,每个 Bot 在其中建立一个 Grok Bot 会话。Coda 运行机需要 Node 24(使用 Coda 自带 Node);云电脑脚本需要 Node 22 或以上。
|
|
12
|
+
凭据保存在 `CODA_PLUGIN_STATE/native-auth.json`,权限 0600;不读取桌面客户端凭据库。收到认证拒绝时,用本插件登录所得的 refreshToken 尝试官方 OAuth 续期并保存轮换凭据;没有 refreshToken 或续期失败时提示 `/login` 重新登录。续期仅用假服务端测试,尚未实测真实 token 到期。配置项探测不会自动登录,也不为未选择 Bot 的会话留下状态文件。
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
2. Bot 下载 `remote/coda-bot.mjs` 到 `/workspace/.coda-bot/coda-bot.mjs`,运行 `node coda-bot.mjs net`,按需完成 Tailscale 登录,然后 `node coda-bot.mjs bind <address> <code>`。后端入口需在云电脑可达的 tailnet 中。
|
|
14
|
-
3. 在 Bot 设置中添加自定义 MCP 服务,命令 `node /workspace/.coda-bot/coda-bot.mjs mcp`;读取并安装同目录的 `remote/SKILL.md`。
|
|
15
|
-
4. 可选:在 Grok Bot 的应用里连接 GitHub。巡检定位问题时,Skill 会用它读 coda、coda-state、coda-plugins 的代码并在汇报里写明文件与行号;Skill 只让它读,需要改代码时经 Coda 卡片批准后创建独立工作会话,由该会话的 Agent 按仓库惯例提交、开 PR 并完成评审。
|
|
16
|
-
5. 建两个 Routine:Webhook 触发处理 Coda 消息;定时巡检按 Skill 调只读工具,没发现问题不说话。
|
|
17
|
-
6. 把 Webhook 地址与 Bearer key 填到插件设置 `grok-bot.bots` 的同一行,行名为 Bot 名,key 是 writeOnly 密文。会话选择「这是哪个 Bot」。key 需要传递时使用 Grok 密钥卡片,不贴聊天。设置变更后重开会话,让进程读到新的设置环境。
|
|
14
|
+
Coda 的 `systemPrompt` 接受格式校验,但不发送给 Grok、不保存,也不拼进用户消息。Bot 的设定和 Skill 留在 Grok,避免改变原生会话的设定与截断用户正文;Coda 工具的授权由后端实施。
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
已绑定会话可发送 `/bind` 获取新绑定码,例如云端配置丢失后重新接入。
|
|
16
|
+
## 设置与运行记录
|
|
21
17
|
|
|
22
|
-
|
|
18
|
+
| 设置 | 含义 |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `grok-bot.telemetry` | 可选看板基地址,只能读取其 `/api/` 路径 |
|
|
21
|
+
| `grok-bot.turnTimeout` | 默认 `PT30M`,Coda 等待一轮的上限;超时不自动重发,云端未必停止 |
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
巡检日程与内容在 Grok Routine 中配置,Coda 不创建巡检计时器。收到 subagents/automations 变化通知或 `hasRunningSubagents` 时刷新运行记录;活动期间每 5 秒核对,空闲时不持续轮询。首次连接只建立已结束记录的基线,重连补齐新记录。只保存最近 200 条已结束记录及仍运行的记录,已淘汰的旧历史不会反复显示。
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
每个 Routine run 显示一条活动,例如「定时任务「Coda 巡检」运行中 → 成功(72 秒)」;失败或记录丢失会明确说明。run ID 对应 executor ID `sand-subagent-<runId>`。executor 内部正文、命令与输出不追踪。主会话结论的 requestId 与 automation run 的 requestId 不同,没有验证到直接关联字段,所以不把并发结论猜测归属到某条 run。
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
订阅正常到期、游标重置后静默重连;暂时失败只写日志并退避。连续 3 次失败才在会话提示一次,认证失效立即提示,恢复后提示一次。Routine 刷新采用相同策略。
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
主会话订阅用 `_coda/wait` 保持活动;自主运行用 handling/waiting 切换轮次。Coda 的未结束等待项会阻止 Agent 空闲回收;退出、断网或归档时不镜像,重新加载后按游标补齐。Grok 的 Routine 执行不依赖 Coda 在线。
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
| --- | --- |
|
|
34
|
-
| `grok-bot.bots` | Bot 名到 `{webhook, key}` 的表格;key 经 `CODA_PLUGIN_SECRETS` 中的文件路径读取 |
|
|
35
|
-
| `grok-bot.telemetry` | 看板 HTTP(S) 基地址,无默认值;只允许 GET `/api/` 路径,不跟随重定向 |
|
|
36
|
-
| `grok-bot.turnTimeout` | 默认 `PT30M`;从消息开始或自主轮次开始计时,包含 Webhook、审批与工具等待 |
|
|
31
|
+
## Coda 数据与授权工具(可选)
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
会话指令经 `hello`(绑定时)及每次 `coda_input` 的 instructions 字段返回;Bot 应应用当前指令,替换之前保存的版本(包括清空),无需重新绑定。响应丢失、重新绑定或切换 Bot 都不会清空指令,不再随 Webhook 发送。
|
|
40
|
-
消息以 `{source:"coda", sessionId, turn, kind, preview}` POST 到 Routine。preview 只含正文前 500 个 Unicode 字符,截断时追加「……(完整内容用 coda_input 取)」;kind 根据可识别的 Coda 前缀区分 result、notification,其余为 message。Bot 每次必须先用 `coda_input(turn)` 取完整正文,再做事。完整输入只在内存中保留,轮次结束后仍可读,下一轮(包括自主轮次)开始即替换;进程重启后不可恢复。Bearer key 只送到配置的地址,禁止重定向。HTTP 200 只确认触发,`say` 输出、`done` 完成本轮。失败不自动重发,避免重复操作。
|
|
41
|
-
取消会立即回 `cancelled`;超时说明原因后结束本轮。两者都不保证云端停止。旧 turn 的输出被拒绝;Coda 发起的轮次必须带 turn,省略它不会混入新一轮。
|
|
33
|
+
发送 `/bind`,按返回说明让 Bot 下载 `remote/coda-bot.mjs` 和 `remote/SKILL.md` 到 `/workspace/.coda-bot/`,执行 `net`、`bind <address> <code>`。绑定需要 [Coda 远端 Agent 入口](https://github.com/coda-console/coda/pull/2006)。
|
|
42
34
|
|
|
43
|
-
|
|
44
|
-
副作用只放行 `new_session` 和 `send`,必须在一轮内调用;没有轮次时先开自主轮次。适配器向 coda MCP 的 status 取 turn 票据,并由本轮 ID、工具名和规范化参数的 SHA-256 生成稳定 requestId。对象键递归排序,数组顺序保留,参数顶层 turn/requestId 不参与计算;同轮等价重试复用 ID,不同参数或轮次是新调用。
|
|
35
|
+
在 Grok Bot 设置添加自定义 stdio MCP:
|
|
45
36
|
|
|
46
|
-
|
|
37
|
+
```sh
|
|
38
|
+
node /workspace/.coda-bot/coda-bot.mjs mcp <Coda-sessionId>
|
|
39
|
+
```
|
|
47
40
|
|
|
48
|
-
`
|
|
41
|
+
`net` 将 Tailscale 状态放在 `/workspace/.coda-bot/tailscale/`;缺软件时安装,恢复守护进程,必要时显示登录链接。多个 Bot 共用云电脑,MCP 命令必须指定各自的 Coda sessionId。`bind` 只确认工具绑定,不再向 Bot 交付 Coda systemPrompt。
|
|
49
42
|
|
|
50
|
-
|
|
43
|
+
工具包括 `read_thread`、`read_tool_call`、`agents`、`status`、`workflow_status`、`workflow_list`、`telemetry`、`machines`、`machine_diagnostics`、`new_session`、`send`(MCP 名加 `coda_` 前缀)。Coda 工具调用保留完整输入输出;轮外只读调用仅写日志,不开启空轮次。`new_session` 在目标目录开独立会话,`send` 给已有会话发消息,均由 Coda 授权卡片批准;稳定 requestId、turn 票据和后台 send 结果由适配器处理。
|
|
51
44
|
|
|
52
|
-
|
|
45
|
+
诊断工具需在 Coda 安装并为目录启用内置「机器诊断」插件(`diagnostics` MCP),参见 [诊断工具](https://github.com/coda-console/coda/pull/2003);遥测端点参见 [遥测 JSON 接口](https://github.com/coda-console/coda/pull/2004)。没有 new_session 的旧 Coda 会如实报不可用。
|
|
53
46
|
|
|
54
|
-
|
|
55
|
-
Coda 将 HTTP 调用包装成 `_coda/remote {sessionId,method,params}`;插件结果和 JSON-RPC 错误原样交回。脚本不重试 401、409、502、504 或其他失败。
|
|
47
|
+
## 从 0.1.7 升级
|
|
56
48
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
| `done` | `{turn?}` → `{}`,等待工具全部返回后调用 |
|
|
63
|
-
| `tool` | `{name, arguments, turn?}` → Coda MCP 工具结果 |
|
|
64
|
-
| `telemetry` | `{path, query?, turn?}` → 看板 JSON 原样返回 |
|
|
49
|
+
1. 删除不再需要的「Coda 消息」Webhook Routine(`coda`)。
|
|
50
|
+
2. **保留「Coda 巡检」定时 Routine(`coda-2`)**。先由用户确认,再通过原生消息让 Bot 自己把 Routine 指令中的 `coda_input`、`coda_say`、`coda_done` 改成「有发现就在对话里告诉用户」。
|
|
51
|
+
3. 升级插件、完成原生登录并重新选择 Bot。在云电脑重新下载 `coda-bot.mjs`、`SKILL.md`,重启 MCP;按 `/bind` 说明核对工具绑定。
|
|
52
|
+
|
|
53
|
+
旧 Webhook bots 表不再使用;`coda_input`、`coda_say`、`coda_done` 及适配器对应远端方法已移除。本次代码更新不会自行修改线上 Routine。
|
|
65
54
|
|
|
66
|
-
|
|
67
|
-
调试:`node coda-bot.mjs call <method> '<JSON>' [sessionId]`。本轮 ID 与 status 的 Coda turn 票据不同,Bot 只传前者,适配器取得后者。
|
|
55
|
+
## 协议与验证边界
|
|
68
56
|
|
|
69
|
-
|
|
57
|
+
使用 Grok Bot **0.78.1** 的私有 Connect 协议(`api2.cursor.sh`),请求带客户端版本头,协议客户端独立在 `agent/native.mjs`,以后可替换正式接口。只读参考社区 [grok-bot-sdk 协议说明](https://github.com/RongleCat/grok-bot-sdk/blob/main/docs/api/protocol.md)(MIT),未复制其实现或厂商代码。
|
|
70
58
|
|
|
71
|
-
|
|
59
|
+
这是未获稳定性保证的私有接口,上游升级可能使登录、字段或接口失效;错误会显示在会话中。[Cursor 服务条款 §1.5](https://cursor.com/terms-of-service) 限制逆向工程等行为(法律不允许此限制的情况除外),使用前需评估该条款风险。
|
|
72
60
|
|
|
73
|
-
|
|
61
|
+
- SetStatus 只提供名称/说明,映射为 tool_call 标题和状态,不伪造云端工具输入输出。thinking 只显示“正在思考”,不是思考正文。
|
|
62
|
+
- 按 requestId/turnId 归并,成功结束且已有正文时立即核对历史并收尾;没有正文时,等到 serverTimeMs 晚于 settledAtMs 的心跳或 5 秒宽限期,再核对历史并收尾,不把正常的静默运行挂到 turnTimeout。上游没有“最后正文已落盘”标志,极迟消息仍作为补充显示,不静默丢弃。
|
|
63
|
+
- entryId、updatedSeq 和正文摘要只保留按 updatedSeq 排序的最近 2000 条,每批条目合并写盘;更早条目若被服务端修订,会当作新条目呈现。断线按游标重连,游标失效分页读历史补齐并去重,不重发用户消息。首次选择只建立历史基线,不灌入旧聊天。正文被服务端省略或消息类型不支持时明确提示去 Grok 客户端查看。
|
|
64
|
+
- Stop 调 InterruptGrokBotAgentRun。widget 选项变成 Coda 卡片,选择后交回 RespondGrokBotWidget;未选择则不代答。auto-review 尚未实测,收到 awaiting 时提示去 Grok 客户端处理,并记事件日志。
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
回归还覆盖:new_session 的参数透传、轮次票据与稳定 requestId、未提供工具时明确报错、旧 spawn/cancel 拒绝代理、new/load 不再报告审批档位、new_session 不使用 send 的 20 秒计时器,以及新旧 Coda 目录文案和创建/拒绝通知的分类;同时保留 send 后台收尾、done 后真实 Rpc.close() 的可见失败、恢复后无需重新绑定即可获取新指令的回归。本次尚未与配套 new_session 内核及真 Grok Bot 联调;真实卡片、独立根会话创建与通知需要配套 PR 就绪后验证。
|
|
66
|
+
实机已验证官方登录、Bot 列表、原生发送/状态/活动/回复、Interrupt、widget 选择交回、Routine run 与 executor ID 的关联,以及无需任何 Coda 汇报工具也会出现的主会话结论(结束状态比正文早 121 ms)。完整新适配器尚未真机部署;自动续期、auto-review、executor 内部过程未实测。适配器测试使用假原生服务端与假时钟。
|
package/agent/auth.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { API, NativeClient, loginRequest, clientHeaders } from './native.mjs';
|
|
5
|
+
|
|
6
|
+
export function pause(clock, ms, signal) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const abort = () => { clock.clearTimeout(timer); reject(new Error('Stopped')); };
|
|
9
|
+
const timer = clock.setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
|
|
10
|
+
if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export function writePrivate(root, file, value) {
|
|
14
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
15
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
16
|
+
writeFileSync(temp, JSON.stringify(value), { mode: 0o600 });
|
|
17
|
+
renameSync(temp, file);
|
|
18
|
+
}
|
|
19
|
+
export class NativeAuth {
|
|
20
|
+
constructor({ root, fetcher, clock }) {
|
|
21
|
+
Object.assign(this, { root, fetcher, clock });
|
|
22
|
+
this.file = root && join(root, 'native-auth.json');
|
|
23
|
+
if (this.file) {
|
|
24
|
+
try { this.use(JSON.parse(readFileSync(this.file, 'utf8'))); }
|
|
25
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
use(auth) {
|
|
29
|
+
if (typeof auth.accessToken !== 'string' || !auth.accessToken) throw new Error('登录响应缺少 accessToken');
|
|
30
|
+
this.credentials = auth;
|
|
31
|
+
this.transport = new NativeClient({ token: auth.accessToken, teamId: auth.selectedTeamId ?? auth.teamId, fetcher: this.fetcher });
|
|
32
|
+
this.client ??= { request: (...args) => this.request(...args) };
|
|
33
|
+
}
|
|
34
|
+
async request(method, value, options) {
|
|
35
|
+
const auth = this.credentials;
|
|
36
|
+
try { return await this.transport.request(method, value, options); }
|
|
37
|
+
catch (error) {
|
|
38
|
+
// Only a rejected authentication is retried. Never retry an ambiguous
|
|
39
|
+
// transport failure for a message or widget response.
|
|
40
|
+
if (error.status !== 401 || !auth.refreshToken) throw error;
|
|
41
|
+
if (this.credentials === auth) await this.refresh(auth);
|
|
42
|
+
return this.transport.request(method, value, options);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async refresh(auth) {
|
|
46
|
+
if (this.renewing) return this.renewing;
|
|
47
|
+
this.renewing = (async () => {
|
|
48
|
+
const response = await this.fetcher(`${API}/oauth/token`, {
|
|
49
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
|
|
50
|
+
headers: { ...clientHeaders(), 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ client_id: 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB', grant_type: 'refresh_token', refresh_token: auth.refreshToken }),
|
|
52
|
+
});
|
|
53
|
+
const renewed = await response.json().catch(() => ({}));
|
|
54
|
+
if (!response.ok || renewed.shouldLogout || typeof renewed.access_token !== 'string' || !renewed.access_token)
|
|
55
|
+
throw Object.assign(new Error('Grok 登录续期失败,请发送 /login 重新登录'), { status: 401 });
|
|
56
|
+
if (this.credentials !== auth) return; // A completed login supersedes an old refresh.
|
|
57
|
+
const next = { ...auth, accessToken: renewed.access_token, refreshToken: renewed.refresh_token ?? auth.refreshToken };
|
|
58
|
+
writePrivate(this.root, this.file, next);
|
|
59
|
+
this.use(next);
|
|
60
|
+
})().finally(() => { this.renewing = null; });
|
|
61
|
+
return this.renewing;
|
|
62
|
+
}
|
|
63
|
+
login() {
|
|
64
|
+
if (this.pending) return this.pending;
|
|
65
|
+
if (!this.file) throw new Error('缺少 CODA_PLUGIN_STATE,无法保存登录');
|
|
66
|
+
const request = loginRequest(), controller = new AbortController();
|
|
67
|
+
const url = new URL('/auth/poll', API);
|
|
68
|
+
url.search = new URLSearchParams({ uuid: request.uuid, verifier: request.verifier });
|
|
69
|
+
const timer = this.clock.setTimeout(() => controller.abort(), 15 * 60_000);
|
|
70
|
+
const promise = (async () => {
|
|
71
|
+
try {
|
|
72
|
+
for (;;) {
|
|
73
|
+
const response = await this.fetcher(url, { headers: clientHeaders(), redirect: 'error', signal: controller.signal });
|
|
74
|
+
if (response.status === 404) { await pause(this.clock, 2000, controller.signal); continue; }
|
|
75
|
+
if (!response.ok) throw new Error(`登录失败 HTTP ${response.status};请发送 /login 重试`);
|
|
76
|
+
const auth = await response.json();
|
|
77
|
+
if (typeof auth.accessToken !== 'string' || !auth.accessToken) throw new Error('登录响应缺少 accessToken');
|
|
78
|
+
writePrivate(this.root, this.file, auth);
|
|
79
|
+
this.use(auth);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
} finally { this.clock.clearTimeout(timer); this.pending = null; }
|
|
83
|
+
})();
|
|
84
|
+
this.pending = { url: request.url, promise, controller };
|
|
85
|
+
return this.pending;
|
|
86
|
+
}
|
|
87
|
+
close() { this.pending?.controller.abort(); }
|
|
88
|
+
}
|
package/agent/health.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A connection frame alone is not recovery: require useful data, a heartbeat,
|
|
2
|
+
// or a clean stream end. This also counts repeated post-connect failures.
|
|
3
|
+
export const authenticationError = error => error.status === 401 || error.status === 403 || /unauthenticated/.test(error.message);
|
|
4
|
+
export class ConnectionHealth {
|
|
5
|
+
constructor({ log, error, recovered }) {
|
|
6
|
+
Object.assign(this, { log, error, recovered });
|
|
7
|
+
this.failures = 0;
|
|
8
|
+
this.reported = false;
|
|
9
|
+
}
|
|
10
|
+
failure(error) {
|
|
11
|
+
this.log(error.message);
|
|
12
|
+
this.failures++;
|
|
13
|
+
if (!this.reported && (authenticationError(error) || this.failures >= 3)) {
|
|
14
|
+
this.reported = true;
|
|
15
|
+
this.error(error);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
success() {
|
|
19
|
+
this.failures = 0;
|
|
20
|
+
if (this.reported) { this.reported = false; this.recovered(); }
|
|
21
|
+
}
|
|
22
|
+
get delay() { return Math.min(30_000, 1000 * 2 ** Math.min(Math.max(0, this.failures - 1), 5)); }
|
|
23
|
+
}
|
package/agent/native.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Grok Bot 0.78.1 private Connect protocol; no vendor source or desktop credentials.
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
export const CLIENT_VERSION = '0.78.1';
|
|
4
|
+
export const clientHeaders = () => ({ 'x-cursor-client-version': CLIENT_VERSION, 'x-cursor-client-type': 'sand',
|
|
5
|
+
'x-cursor-client-source': 'sand-desktop', 'x-cursor-client-os': 'darwin', 'x-sand-box-namespace': 'prod' });
|
|
6
|
+
export const API = 'https://api2.cursor.sh';
|
|
7
|
+
const SERVICE = 'aiserver.v1.GrokBotService';
|
|
8
|
+
export function loginRequest() {
|
|
9
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
10
|
+
const uuid = randomUUID();
|
|
11
|
+
const url = new URL('https://cursor.com/loginDeepControl');
|
|
12
|
+
url.search = new URLSearchParams({ challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
13
|
+
uuid, mode: 'login', redirectTarget: 'sand', supportsSelectedTeamLogin: 'true' });
|
|
14
|
+
return { verifier, uuid, url: url.toString() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function envelope(value) {
|
|
18
|
+
const bytes = Buffer.from(JSON.stringify(value));
|
|
19
|
+
const header = Buffer.alloc(5);
|
|
20
|
+
header.writeUInt32BE(bytes.length, 1);
|
|
21
|
+
return Buffer.concat([header, bytes]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function* frames(body) {
|
|
25
|
+
let buffered = Buffer.alloc(0);
|
|
26
|
+
for await (const chunk of body) {
|
|
27
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
28
|
+
while (buffered.length >= 5) {
|
|
29
|
+
const flags = buffered[0], length = buffered.readUInt32BE(1);
|
|
30
|
+
if (length > 32 * 1024 * 1024) throw new Error('Connect frame exceeds 32 MiB');
|
|
31
|
+
if (flags & ~3) throw new Error('Unknown Connect frame flags');
|
|
32
|
+
if (buffered.length < 5 + length) break;
|
|
33
|
+
const bytes = buffered.subarray(5, 5 + length);
|
|
34
|
+
buffered = buffered.subarray(5 + length);
|
|
35
|
+
if (flags & 1) throw new Error('Unexpected compressed Connect frame');
|
|
36
|
+
let message;
|
|
37
|
+
try { message = JSON.parse(bytes.toString('utf8')); }
|
|
38
|
+
catch { throw new Error('Invalid Connect JSON frame'); }
|
|
39
|
+
if (flags & 2) {
|
|
40
|
+
if (message.error) throw new Error(`Connect stream failed: ${errorCode(message.error.code)}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
yield message;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (buffered.length) throw new Error('Incomplete Connect frame');
|
|
47
|
+
throw new Error('Connect stream ended without its terminal frame');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function errorCode(value) {
|
|
51
|
+
return typeof value === 'string' && /^[a-z_]{1,40}$/.test(value) ? value : 'unknown';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class NativeClient {
|
|
55
|
+
constructor({ token, teamId, fetcher = fetch, base = API }) {
|
|
56
|
+
Object.assign(this, { token, teamId, fetcher, base });
|
|
57
|
+
}
|
|
58
|
+
async request(method, value, { stream = false, signal = AbortSignal.timeout(30_000) } = {}) {
|
|
59
|
+
const headers = { ...clientHeaders(), authorization: `Bearer ${this.token}`, 'connect-protocol-version': '1',
|
|
60
|
+
'content-type': stream ? 'application/connect+json' : 'application/json',
|
|
61
|
+
...(stream ? { accept: 'application/connect+json', 'connect-accept-encoding': 'identity' } : {}),
|
|
62
|
+
'x-request-id': randomUUID(), 'x-ghost-mode': 'true' };
|
|
63
|
+
if (this.teamId != null) headers['x-cursor-team-id'] = String(this.teamId);
|
|
64
|
+
const response = await this.fetcher(`${this.base}/${SERVICE}/${method}`, {
|
|
65
|
+
method: 'POST', headers, body: stream ? envelope(value) : JSON.stringify(value), signal, redirect: 'error',
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
const code = (await response.json().catch(() => ({}))).code;
|
|
69
|
+
throw Object.assign(new Error(`${method}: HTTP ${response.status} (${errorCode(code)})`), { status: response.status });
|
|
70
|
+
}
|
|
71
|
+
return stream ? frames(response.body) : response.json();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Routine execution stays in Grok. Only run records are mirrored; executor
|
|
2
|
+
// transcripts are neither queried nor inferred from the main conversation.
|
|
3
|
+
import { ConnectionHealth, authenticationError } from './health.mjs';
|
|
4
|
+
export const RUN_CACHE_LIMIT = 200;
|
|
5
|
+
export const ROUTINE_POLL_MS = 5000;
|
|
6
|
+
export class RoutineWatch {
|
|
7
|
+
constructor({ client, bot, saved, clock, commit, notify, error, recovered, log }) {
|
|
8
|
+
Object.assign(this, { client, bot, saved, clock, commit, notify, error, recovered, log });
|
|
9
|
+
this.health = new ConnectionHealth({ error, recovered, log });
|
|
10
|
+
this.controller = new AbortController();
|
|
11
|
+
this.registered = new Set();
|
|
12
|
+
this.activeHint = false;
|
|
13
|
+
this.saved.runs ??= {};
|
|
14
|
+
}
|
|
15
|
+
hint(active) {
|
|
16
|
+
if (this.controller.signal.aborted) return;
|
|
17
|
+
if (active !== undefined) this.activeHint = active;
|
|
18
|
+
if (this.flight) { this.again = true; return this.flight; }
|
|
19
|
+
this.clock.clearTimeout(this.timer);
|
|
20
|
+
this.flight = (async () => {
|
|
21
|
+
do {
|
|
22
|
+
this.again = false;
|
|
23
|
+
await this.refresh();
|
|
24
|
+
if (this.controller.signal.aborted) return;
|
|
25
|
+
this.health.success();
|
|
26
|
+
} while (this.again && !this.controller.signal.aborted);
|
|
27
|
+
})().catch(error => { if (!this.controller.signal.aborted) {
|
|
28
|
+
this.health.failure(error);
|
|
29
|
+
if (authenticationError(error)) this.close();
|
|
30
|
+
} }).finally(() => {
|
|
31
|
+
this.flight = null;
|
|
32
|
+
if (!this.controller.signal.aborted && (this.health.failures || this.activeHint || Object.values(this.saved.runs).some(r => r.status === 'running')))
|
|
33
|
+
this.timer = this.clock.setTimeout(() => { void this.hint(); }, Math.max(ROUTINE_POLL_MS, this.health.delay));
|
|
34
|
+
});
|
|
35
|
+
return this.flight;
|
|
36
|
+
}
|
|
37
|
+
activity(run, state, title) {
|
|
38
|
+
this.notify({ waitId: `grok-routine:${this.bot}:${run.id}`, kind: 'task', state, title });
|
|
39
|
+
}
|
|
40
|
+
async refresh() {
|
|
41
|
+
const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(30_000)]);
|
|
42
|
+
const [agents, automations] = await Promise.all([
|
|
43
|
+
this.client.request('ListGrokBotSubagents', { agentId: this.bot }, { signal }),
|
|
44
|
+
this.client.request('ListGrokBotAgentAutomations', { agentId: this.bot }, { signal }),
|
|
45
|
+
]);
|
|
46
|
+
if (this.controller.signal.aborted) return;
|
|
47
|
+
const executors = new Set((agents.subagents ?? []).map(a => a.subagentId));
|
|
48
|
+
this.activeHint = executors.size > 0;
|
|
49
|
+
const returned = new Set();
|
|
50
|
+
const baseline = !this.saved.routinesInitialized;
|
|
51
|
+
for (const item of automations.automations ?? []) {
|
|
52
|
+
const record = JSON.parse(item.recordJson);
|
|
53
|
+
if (typeof record.name !== 'string' || !Array.isArray(record.runs)) throw new Error('Grok Routine 记录格式已变化');
|
|
54
|
+
for (const raw of record.runs) {
|
|
55
|
+
if (typeof raw.id !== 'string' || typeof raw.status !== 'string' || !Number.isFinite(raw.startedAt) || (raw.status !== 'running' && !Number.isFinite(raw.finishedAt)))
|
|
56
|
+
throw new Error('Grok Routine 运行记录缺少 ID、状态或时间');
|
|
57
|
+
const run = { id: raw.id, name: record.name, status: raw.status, startedAt: raw.startedAt, finishedAt: raw.finishedAt,
|
|
58
|
+
automationId: item.automationId, requestId: raw.requestId, executorId: `sand-subagent-${raw.id}` };
|
|
59
|
+
returned.add(run.id);
|
|
60
|
+
const before = this.saved.runs[run.id];
|
|
61
|
+
// Completed history on first selection is a baseline, not new work.
|
|
62
|
+
if (((baseline || (!before && run.startedAt <= (this.saved.runsBefore ?? -1))) && run.status !== 'running') || (before && before.status === run.status && run.status !== 'running')) {
|
|
63
|
+
this.saved.runs[run.id] = run; continue;
|
|
64
|
+
}
|
|
65
|
+
const title = `定时任务「${run.name}」`;
|
|
66
|
+
if (!this.registered.has(run.id)) {
|
|
67
|
+
this.registered.add(run.id);
|
|
68
|
+
this.activity(run, 'waiting', `${title}运行中`);
|
|
69
|
+
}
|
|
70
|
+
if (run.status !== 'running') {
|
|
71
|
+
const seconds = Math.max(0, Math.round((run.finishedAt - run.startedAt) / 1000));
|
|
72
|
+
this.activity(run, run.status === 'ok' ? 'done' : 'failed', `${title}${run.status === 'ok' ? '成功' : `失败(${run.status})`}(${seconds} 秒)`);
|
|
73
|
+
}
|
|
74
|
+
this.saved.runs[run.id] = run;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const run of Object.values(this.saved.runs)) {
|
|
78
|
+
if (run.status === 'running' && !returned.has(run.id) && !executors.has(run.executorId)) {
|
|
79
|
+
if (!this.registered.has(run.id)) this.activity(run, 'waiting', `定时任务「${run.name}」核对结果`);
|
|
80
|
+
this.activity(run, 'failed', `定时任务「${run.name}」已不可见,无法确认结果;请在 Grok 客户端查看`);
|
|
81
|
+
run.status = 'unavailable';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Keep every active run plus the newest completed records. The timestamp
|
|
85
|
+
// floor prevents old server history being announced again after eviction.
|
|
86
|
+
const completed = Object.values(this.saved.runs).filter(r => r.status !== 'running').sort((a, b) => b.startedAt - a.startedAt);
|
|
87
|
+
for (const run of completed.slice(RUN_CACHE_LIMIT)) {
|
|
88
|
+
this.saved.runsBefore = Math.max(this.saved.runsBefore ?? -1, run.startedAt);
|
|
89
|
+
delete this.saved.runs[run.id];
|
|
90
|
+
this.registered.delete(run.id);
|
|
91
|
+
}
|
|
92
|
+
this.saved.routinesInitialized = true;
|
|
93
|
+
this.commit();
|
|
94
|
+
}
|
|
95
|
+
close() {
|
|
96
|
+
this.controller.abort();
|
|
97
|
+
this.clock.clearTimeout(this.timer);
|
|
98
|
+
for (const id of this.registered) {
|
|
99
|
+
const run = this.saved.runs[id];
|
|
100
|
+
if (run?.status === 'running') this.activity(run, 'failed', `定时任务「${run.name}」已停止跟踪,结果未确认;Grok 中的任务未必停止`);
|
|
101
|
+
}
|
|
102
|
+
this.registered.clear();
|
|
103
|
+
}
|
|
104
|
+
}
|