@coda-console/grok-bot 0.1.2 → 0.1.4
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 +13 -5
- package/agent/session.mjs +105 -35
- package/agent/setup.mjs +1 -1
- package/package.json +2 -1
- package/remote/SKILL.md +6 -4
- package/remote/coda-bot.mjs +6 -4
package/README.md
CHANGED
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
|
|
22
22
|
脚本与 Skill 的默认下载地址是本仓库 main 的 GitHub raw URL;草稿 PR 验证时改成这个 PR 的提交 SHA,合并前 main 没有这些文件。新 npm 包首次发布须按仓库 README 的 bootstrap/Trusted Publishing 步骤配置,由维护者执行。
|
|
23
23
|
|
|
24
|
+
升级到 0.1.4 时,已绑定 Bot 也须更新云电脑上的 `coda-bot.mjs` 与 Skill,并重启 MCP 服务,以使用 `coda_input` 并应用它返回的当前指令。仅更新 Coda 插件不足以处理新的 Webhook 正文格式。
|
|
25
|
+
|
|
24
26
|
## 设置与生命周期
|
|
25
27
|
|
|
28
|
+
会话的「派出的 Agent」默认「要你批准」,可切到「自动」,也可切回人工。想让派出的 Agent 不再要求用户逐次点审批,就把这个选项设为「自动」;Coda 会为子 Agent 选择它自己的自动档位(如 Codex 的 agent 档位,由自动审核按风险判断越权操作)。这个设置只影响派出的 Agent,Bot 自己开 Agent、给别的会话发消息仍由 Coda 授权卡片批准。适配器通过 ACP modes / session/set_mode 报告和切换档位;选择由 Coda 保存并在恢复后重新应用,不写入插件绑定文件。
|
|
29
|
+
|
|
26
30
|
| 设置 | 作用 |
|
|
27
31
|
| --- | --- |
|
|
28
32
|
| `grok-bot.bots` | Bot 名到 `{webhook, key}` 的表格;key 经 `CODA_PLUGIN_SECRETS` 中的文件路径读取 |
|
|
@@ -30,13 +34,16 @@
|
|
|
30
34
|
| `grok-bot.turnTimeout` | 默认 `PT30M`;从消息开始或自主轮次开始计时,包含 Webhook、审批与工具等待 |
|
|
31
35
|
|
|
32
36
|
仅在绑定或选择、切换 Bot 时,将 Bot 选择与绑定状态写入 `CODA_PLUGIN_STATE`;创建和恢复会话不写文件,读取模型和配置项的探测不会留下状态文件。恢复没有状态文件的会话时按未绑定处理。会话指令只保留在内存,由 Coda 在 session/new、session/load 时提供,不落盘。初始化声明 `loadSession`、`sessionCapabilities.systemPrompt`、`_meta.coda.waits` 和 `_meta.coda.remote`。
|
|
33
|
-
|
|
34
|
-
消息以 `{source:"coda", sessionId, turn,
|
|
35
|
-
取消会立即回 `cancelled`;超时说明原因后结束本轮。两者都不保证云端停止。旧 turn
|
|
37
|
+
会话指令经 `hello`(绑定时)及每次 `coda_input` 的 instructions 字段返回;Bot 应应用当前指令,替换之前保存的版本(包括清空),无需重新绑定。响应丢失、重新绑定或切换 Bot 都不会清空指令,不再随 Webhook 发送。
|
|
38
|
+
消息以 `{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` 完成本轮。失败不自动重发,避免重复操作。
|
|
39
|
+
取消会立即回 `cancelled`;超时说明原因后结束本轮。两者都不保证云端停止。旧 turn 的输出被拒绝;Coda 发起的轮次必须带 turn,省略它不会混入新一轮。
|
|
36
40
|
|
|
37
41
|
会话加载时登记 recurring timer。Bot 主动 say 先报 handling,再输出;done 回 waiting。只读调用若没有轮次只写标准错误审计,不开启空轮次;轮内通过 `tool_call` / `tool_call_update` 留下输入与结果。
|
|
38
42
|
副作用调用由适配器向 coda MCP 的 status 取 turn 票据并生成 requestId,授权与通知复用 Coda 会话机制。`spawn` 必须指定目标 `project`,缺失直接拒绝(Bot 的空目录没有代码);开 Agent、给别的会话发消息由 Coda 的授权卡片批准。给自己开出的子会话补充是已批准任务的延续,不另问;`cancel` 只能停自己开出的子会话,由 Coda 校验归属,不另问。适配器不另发审批卡片,工具范围之外全部拒绝。
|
|
39
43
|
|
|
44
|
+
`spawn`、`send`、`cancel` 的 requestId 由本轮 ID、工具名和规范化参数的 SHA-256 生成 UUID;对象键递归排序,数组顺序保留,参数顶层的 turn/requestId 不参与计算。同一轮内参数等价的重试复用同一 ID,由 Coda 恢复原调用;改参数或换轮次会得到新 ID。
|
|
45
|
+
`spawn`、`send` 提交给 Coda 后,超过 20 秒仍未返回就先回复「已提交,正在等用户在 Coda 卡片上批准;结果会作为新消息送来;不要重试或轮询」。阈值为适配器的 `SUBMISSION_WAIT_MS` 常量。提前回执前以原 toolCallId 登记独立后台活动,原调用继续执行;实际返回或 MCP 断连时,活动报 handling,补交结果、终结原工具记录并报 done/failed。原轮次已结束时由 Coda 开自主轮次交回,失败会明确显示给用户,不自动重试。没有 waits 能力的客户端会继续等实际结果,不提前回执。收到回执后可 coda_done,等待批准的子会话不在 status.children 中不代表失败,不要改写同一任务重复派出。
|
|
46
|
+
|
|
40
47
|
## 远端协议与调试
|
|
41
48
|
|
|
42
49
|
绑定:`POST /v1/remote-agent/bind {code}` 得到 token;脚本再经带 Bearer token 的 `POST /v1/remote-agent {method:"hello",params:{}}` 确认绑定、取 sessionId 和初始会话指令。token 不进终端输出。
|
|
@@ -45,12 +52,13 @@ Coda 将 HTTP 调用包装成 `_coda/remote {sessionId,method,params}`;插件
|
|
|
45
52
|
| 方法 | 参数与返回 |
|
|
46
53
|
| --- | --- |
|
|
47
54
|
| `hello` | `{}` → `{sessionId, bot, instructions}`,bind 完成时调用;幂等读取指令 |
|
|
55
|
+
| `input` | `{turn}` → `{turn, kind, text, instructions}`,读取完整正文与当前会话指令,不开启轮次 |
|
|
48
56
|
| `say` | `{text, turn?}` → `{turn}`,首次主动汇报可省略 turn |
|
|
49
57
|
| `done` | `{turn?}` → `{}`,等待工具全部返回后调用 |
|
|
50
58
|
| `tool` | `{name, arguments, turn?}` → Coda MCP 工具结果 |
|
|
51
59
|
| `telemetry` | `{path, query?, turn?}` → 看板 JSON 原样返回 |
|
|
52
60
|
|
|
53
|
-
会话建立与恢复时,仅为 mcpServers 中收到的 `coda` 和 `diagnostics` 启动 stdio MCP 客户端,用各自 `tools/list` 建立路由,只保留白名单。`machines`、`machine_diagnostics` 由「机器诊断」插件的 `diagnostics` 提供;未收到该服务器时不启动额外进程,调用这两个工具会提示「这两个工具需要在 Coda 里安装并为这个目录启用『机器诊断』插件」。服务没列出工具时明确报不可用,不转给 coda。其余只读工具:read_thread、read_tool_call、agents、status、workflow_status、workflow_list;副作用工具:spawn、send、cancel。工具名冲突拒绝调用。Bot MCP 以 `coda_` 前缀暴露这些工具,加 coda_say、coda_done、coda_telemetry。
|
|
61
|
+
会话建立与恢复时,仅为 mcpServers 中收到的 `coda` 和 `diagnostics` 启动 stdio MCP 客户端,用各自 `tools/list` 建立路由,只保留白名单。`machines`、`machine_diagnostics` 由「机器诊断」插件的 `diagnostics` 提供;未收到该服务器时不启动额外进程,调用这两个工具会提示「这两个工具需要在 Coda 里安装并为这个目录启用『机器诊断』插件」。服务没列出工具时明确报不可用,不转给 coda。其余只读工具:read_thread、read_tool_call、agents、status、workflow_status、workflow_list;副作用工具:spawn、send、cancel。工具名冲突拒绝调用。Bot MCP 以 `coda_` 前缀暴露这些工具,加 coda_input、coda_say、coda_done、coda_telemetry。
|
|
54
62
|
调试:`node coda-bot.mjs call <method> '<JSON>' [sessionId]`。本轮 ID 与 status 的 Coda turn 票据不同,Bot 只传前者,适配器取得后者。
|
|
55
63
|
|
|
56
64
|
`coda_machine_diagnostics` 的参数直接传入:`machine` 为 ID 或唯一名字,`kind` 为 network / client-performance;`from`、`to` 为 Unix 毫秒数,`days` 为 1–30 的整数,另有 `limit`(1–500)、`client`、`verdict`。client-performance 只支持 days;network 的 from/to 选择分钟样本,其余参数筛选探测报告。
|
|
@@ -60,4 +68,4 @@ Coda 将 HTTP 调用包装成 `_coda/remote {sessionId,method,params}`;插件
|
|
|
60
68
|
## 验证范围
|
|
61
69
|
|
|
62
70
|
`test/plugins/grok-bot/` 使用假的 ACP Coda 对端、独立的 coda/diagnostics stdio MCP 服务、HTTP Webhook/远端入口和注入时钟;不需要账号或真实业务等待。仓库契约测试包含新插件握手。回归覆盖 hello 响应丢失后重新绑定、切换 Bot、遥测控制字符与路径越界;Tailscale 恢复脚本在假的系统命令下实际运行,验证已登录状态迁移、迁移后登录、系统目录丢失后的恢复和停止失败,不启动真实守护进程。
|
|
63
|
-
|
|
71
|
+
回归还覆盖:等价副作用调用复用 requestId、假时钟推进后的提前回执与后台收尾(含 done 后真实 Rpc.close() 的可见失败)、恢复后无需重新绑定即可取到新指令、长正文经 coda_input 完整读取、输入保留和失效边界,以及自动/人工档位切换后仍由 Coda 授权 Bot 的派活与发消息。本次修复尚未在真 Grok Bot 上复测;真实 Routine 投递、云端工具超时和子 Agent 的审批档位继承仍需真机验证。
|
package/agent/session.mjs
CHANGED
|
@@ -5,11 +5,34 @@ import { CodaMcp } from './mcp.mjs';
|
|
|
5
5
|
import { bindingGuide } from './setup.mjs';
|
|
6
6
|
|
|
7
7
|
const MODEL = { id: 'model', name: '模型', category: 'model', type: 'select', currentValue: 'grok-bot', options: [{ value: 'grok-bot', name: 'Grok Bot' }] };
|
|
8
|
+
const MODES = [
|
|
9
|
+
{ id: 'manual', name: '要你批准', description: '派出的 Agent 遇到需要授权的操作时请用户批准' },
|
|
10
|
+
{ id: 'auto', name: '自动', description: '派出的 Agent 使用自己的自动审批档位;Bot 派活与发消息仍由 Coda 授权卡片批准' },
|
|
11
|
+
];
|
|
8
12
|
const READ = new Set(['read_thread', 'read_tool_call', 'agents', 'status', 'workflow_status', 'workflow_list', 'machines', 'machine_diagnostics']);
|
|
9
13
|
const WRITE = new Set(['spawn', 'send', 'cancel']);
|
|
10
14
|
const DIAGNOSTICS = new Set(['machines', 'machine_diagnostics']);
|
|
11
15
|
const fail = message => { throw Object.assign(new Error(message), { code: -32602 }); };
|
|
12
16
|
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
function inputKind(text) {
|
|
18
|
+
if (/^\s*(?:Child session completed:|\[Coda\] (?:以下子会话(?:已结束|交回结果)|以下会话回复|你请求(?:派到其他项目的子会话|发给其他会话的消息)))/u.test(text)) return 'result';
|
|
19
|
+
return /^\s*\[Coda\]/u.test(text) ? 'notification' : 'message';
|
|
20
|
+
}
|
|
21
|
+
export const SUBMISSION_WAIT_MS = 20_000;
|
|
22
|
+
function canonical(value) {
|
|
23
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
24
|
+
if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
function requestId(turn, name, args) {
|
|
28
|
+
const { turn: _ticket, requestId: _request, ...input } = args;
|
|
29
|
+
const bytes = createHash('sha256').update(canonical([turn.id, name, input])).digest().subarray(0, 16);
|
|
30
|
+
// UUIDv8: a custom SHA-256-derived identifier, with UUID version/variant bits.
|
|
31
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x80;
|
|
32
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
33
|
+
const hex = bytes.toString('hex');
|
|
34
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
35
|
+
}
|
|
13
36
|
export function duration(value = 'PT30M') {
|
|
14
37
|
const m = /^P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(value);
|
|
15
38
|
const ms = m && m.slice(1).reduce((sum, n, i) => sum + Number(n ?? 0) * [604800000, 86400000, 3600000, 60000, 1000][i], 0);
|
|
@@ -23,8 +46,8 @@ function url(value) {
|
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
export class Adapter {
|
|
26
|
-
constructor({ rpc, env = process.env, fetcher = fetch, clock = { setTimeout, clearTimeout }, log = text => console.error(text), mcpFactory = (server, cwd) => new CodaMcp(server, cwd) }) {
|
|
27
|
-
Object.assign(this, { rpc, env, fetcher, clock, log, mcpFactory });
|
|
49
|
+
constructor({ rpc, env = process.env, fetcher = fetch, clock = { setTimeout, clearTimeout }, submissionWaitMs = SUBMISSION_WAIT_MS, log = text => console.error(text), mcpFactory = (server, cwd) => new CodaMcp(server, cwd) }) {
|
|
50
|
+
Object.assign(this, { rpc, env, fetcher, clock, submissionWaitMs, log, mcpFactory });
|
|
28
51
|
this.settings = JSON.parse(env.CODA_PLUGIN_SETTINGS || '{}');
|
|
29
52
|
this.secrets = JSON.parse(env.CODA_PLUGIN_SECRETS || '{}');
|
|
30
53
|
this.sessions = new Map();
|
|
@@ -54,6 +77,7 @@ export class Adapter {
|
|
|
54
77
|
if (autonomous && !this.waits) fail('Coda 未声明 waits,不能开启自主轮次');
|
|
55
78
|
const turn = { id: randomUUID(), autonomous, controller: new AbortController(), tools: new Map() };
|
|
56
79
|
turn.result = new Promise(resolve => { turn.resolve = resolve; });
|
|
80
|
+
s.input = null;
|
|
57
81
|
s.turn = turn;
|
|
58
82
|
if (autonomous) this.wait(s, 'handling');
|
|
59
83
|
turn.timer = this.clock.setTimeout(() => {
|
|
@@ -96,7 +120,7 @@ export class Adapter {
|
|
|
96
120
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
97
121
|
}
|
|
98
122
|
const instructions = systemPrompt?.content.map(b => b.text).join('\n') ?? '';
|
|
99
|
-
const s = { id, saved, instructions, diagnosticsEnabled: mcpServers.some(server => server.name === 'diagnostics'), clients: [], routes: new Map(), unavailable: new Map(), turn: null };
|
|
123
|
+
const s = { id, saved, instructions, mode: 'manual', diagnosticsEnabled: mcpServers.some(server => server.name === 'diagnostics'), clients: [], routes: new Map(), unavailable: new Map(), turn: null };
|
|
100
124
|
// Coda and the optional built-in diagnostics plugin are separate stdio MCP servers. Discover
|
|
101
125
|
// both at load time; never treat a tool name returned by a server as authority
|
|
102
126
|
// to expand the whitelist, or fall back to coda for absent diagnostics tools.
|
|
@@ -124,14 +148,16 @@ export class Adapter {
|
|
|
124
148
|
}
|
|
125
149
|
this.sessions.set(id, s);
|
|
126
150
|
this.wait(s, 'waiting');
|
|
127
|
-
return { ...(load ? {} : { sessionId: id }), configOptions: this.configs(s) };
|
|
151
|
+
return { ...(load ? {} : { sessionId: id }), configOptions: this.configs(s), modes: { currentModeId: s.mode, availableModes: MODES } };
|
|
128
152
|
}
|
|
129
153
|
async prompt(s, prompt) {
|
|
130
154
|
if (!Array.isArray(prompt) || prompt.some(block => block.type !== 'text' || typeof block.text !== 'string')) fail('Grok Bot 目前只接受文本;请在文本里提供可读取的附件路径');
|
|
131
155
|
const turn = this.start(s, false);
|
|
156
|
+
const text = prompt.map(b => b.text).join('\n');
|
|
157
|
+
s.input = { turn: turn.id, kind: inputKind(text), text };
|
|
132
158
|
// Return the turn promise immediately; cancellation also finishes while code/webhook is in flight.
|
|
133
159
|
void (async () => {
|
|
134
|
-
if (!s.saved.bound ||
|
|
160
|
+
if (!s.saved.bound || text.trim() === '/bind') {
|
|
135
161
|
if (!this.remote) throw new Error('请先升级到支持远端 Agent 入口的 Coda,再绑定 Grok Bot。');
|
|
136
162
|
const code = await this.rpc.request('_coda/remote/code', { sessionId: s.id }, { signal: turn.controller.signal });
|
|
137
163
|
if (s.turn !== turn) return;
|
|
@@ -144,11 +170,10 @@ export class Adapter {
|
|
|
144
170
|
if (!bot?.webhook || !keyPath) throw new Error('请在插件设置的 bots 表填写这个 Bot 的 Webhook 与密文 key,并在会话里选择它。');
|
|
145
171
|
const key = readFileSync(keyPath, 'utf8').trim();
|
|
146
172
|
if (!key) throw new Error('这个 Bot 的 Webhook key 为空');
|
|
147
|
-
const
|
|
148
|
-
const
|
|
173
|
+
const characters = [...text];
|
|
174
|
+
const preview = characters.slice(0, 500).join('') + (characters.length > 500 ? '……(完整内容用 coda_input 取)' : '');
|
|
149
175
|
const response = await this.fetcher(url(bot.webhook), { method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
150
|
-
body: JSON.stringify({ source: 'coda', sessionId: s.id, turn: turn.id,
|
|
151
|
-
text: instructions ? `<coda-session-instructions>\n${instructions}\n</coda-session-instructions>\n\n${text}` : text }), signal: turn.controller.signal });
|
|
176
|
+
body: JSON.stringify({ source: 'coda', sessionId: s.id, turn: turn.id, kind: s.input.kind, preview }), signal: turn.controller.signal });
|
|
152
177
|
if (!response.ok) throw new Error(`Grok Webhook 返回 HTTP ${response.status};未确认云端是否开始处理,不自动重发。`);
|
|
153
178
|
await response.body?.cancel();
|
|
154
179
|
})().catch(error => {
|
|
@@ -158,21 +183,50 @@ export class Adapter {
|
|
|
158
183
|
});
|
|
159
184
|
return turn.result;
|
|
160
185
|
}
|
|
161
|
-
async trace(s, name, args, operation) {
|
|
186
|
+
async trace(s, name, args, operation, replyAfter) {
|
|
162
187
|
const turn = s.turn, toolCallId = randomUUID();
|
|
163
188
|
const toolCall = { toolCallId, title: `Coda · ${name}`, kind: 'other', status: 'pending', rawInput: args };
|
|
164
189
|
if (turn) { turn.tools.set(toolCallId, true); this.update(s, { sessionUpdate: 'tool_call', ...toolCall }); }
|
|
165
190
|
else this.log(JSON.stringify({ sessionId: s.id, toolCallId, name, arguments: args, state: 'started' }));
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
191
|
+
let background = false;
|
|
192
|
+
const activity = state => this.rpc.notify('_coda/wait', { sessionId: s.id, waitId: toolCallId, state });
|
|
193
|
+
const report = (status, rawOutput) => {
|
|
194
|
+
if (background) {
|
|
195
|
+
// handling opens a Coda autonomous turn when idle and preserves the
|
|
196
|
+
// activity's original cause. Do not reopen the Bot's expired turn.
|
|
197
|
+
activity('handling');
|
|
198
|
+
this.update(s, { sessionUpdate: 'tool_call_update', toolCallId, status, rawOutput });
|
|
199
|
+
this.say(s, status === 'failed'
|
|
200
|
+
? `Coda · ${name} 后台调用失败:${typeof rawOutput === 'string' ? rawOutput : JSON.stringify(rawOutput)}。请核实执行结果,不要自动重发。`
|
|
201
|
+
: `Coda · ${name} 后台调用已返回,结果见该工具记录。`);
|
|
202
|
+
activity(status === 'failed' ? 'failed' : 'done');
|
|
203
|
+
} else if (turn && s.turn === turn) this.update(s, { sessionUpdate: 'tool_call_update', toolCallId, status, rawOutput });
|
|
204
|
+
else this.log(JSON.stringify({ sessionId: s.id, toolCallId, state: status, result: rawOutput }));
|
|
205
|
+
};
|
|
206
|
+
const completed = (async () => {
|
|
207
|
+
try {
|
|
208
|
+
const result = await operation(toolCall, turn);
|
|
209
|
+
report(result?.isError ? 'failed' : 'completed', result);
|
|
210
|
+
return result;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
report('failed', error.message);
|
|
213
|
+
throw error;
|
|
214
|
+
} finally { turn?.tools.delete(toolCallId); }
|
|
215
|
+
})();
|
|
216
|
+
// Without activity support we cannot safely hand off a pending call.
|
|
217
|
+
if (replyAfter === undefined || !this.waits) return completed;
|
|
218
|
+
let timer;
|
|
219
|
+
const submitted = new Promise(resolve => {
|
|
220
|
+
timer = this.clock.setTimeout(() => {
|
|
221
|
+
background = true;
|
|
222
|
+
this.rpc.notify('_coda/wait', { sessionId: s.id, waitId: toolCallId, kind: 'task', state: 'waiting',
|
|
223
|
+
title: `Coda · ${name} 等待结果`, toolCallId });
|
|
224
|
+
turn.tools.delete(toolCallId);
|
|
225
|
+
resolve({ content: [{ type: 'text', text: '已提交,正在等用户在 Coda 卡片上批准;结果会作为新消息送来;不要重试或轮询。没有其他工作就结束本轮。' }] });
|
|
226
|
+
}, replyAfter);
|
|
227
|
+
});
|
|
228
|
+
try { return await Promise.race([completed, submitted]); }
|
|
229
|
+
finally { this.clock.clearTimeout(timer); }
|
|
176
230
|
}
|
|
177
231
|
async tool(s, { name, arguments: args = {}, turn: id }) {
|
|
178
232
|
if (!READ.has(name) && !WRITE.has(name)) fail(`不允许代理工具:${name}`);
|
|
@@ -180,20 +234,23 @@ export class Adapter {
|
|
|
180
234
|
if (name === 'spawn' && (typeof args.project !== 'string' || !args.project.trim()))
|
|
181
235
|
fail('spawn 必须指定目标 project;Bot 所在的空目录没有代码,请选择要修复的项目');
|
|
182
236
|
this.provider(s, name);
|
|
183
|
-
this.active(s, id, WRITE.has(name));
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
237
|
+
const turn = this.active(s, id, WRITE.has(name));
|
|
238
|
+
const signal = turn?.controller.signal;
|
|
239
|
+
let forwarded = args;
|
|
240
|
+
if (WRITE.has(name)) {
|
|
241
|
+
// Coda owns authorization: cross-project spawn and messages to other
|
|
242
|
+
// sessions use its cards; follow-ups/cancel of our children use its ownership checks.
|
|
243
|
+
const status = await this.trace(s, 'status', {}, () => this.provider(s, 'status').call('status', {}, signal));
|
|
244
|
+
if (status.isError) throw new Error('无法取得 Coda turn 票据');
|
|
245
|
+
const ticket = (status.structuredContent ?? JSON.parse(status.content.find(b => b.type === 'text').text)).turn;
|
|
246
|
+
if (typeof ticket !== 'string' || !ticket || s.turn !== turn) fail('没有有效的 Coda turn 票据');
|
|
247
|
+
forwarded = { ...args, turn: ticket, requestId: requestId(turn, name, args) };
|
|
248
|
+
}
|
|
249
|
+
const mayWaitForApproval = name === 'spawn' || name === 'send';
|
|
250
|
+
// Approval calls outlive a Coda turn and must not inherit its abort signal
|
|
251
|
+
// or the MCP client's default 60-second timeout (null disables both).
|
|
252
|
+
return this.trace(s, name, args, () => this.provider(s, name).call(name, forwarded, mayWaitForApproval ? null : signal),
|
|
253
|
+
mayWaitForApproval ? this.submissionWaitMs : undefined);
|
|
197
254
|
}
|
|
198
255
|
provider(s, name) {
|
|
199
256
|
const client = s.routes.get(name);
|
|
@@ -226,8 +283,16 @@ export class Adapter {
|
|
|
226
283
|
return { protocolVersion: 1, agentCapabilities: { loadSession: true, sessionCapabilities: { systemPrompt: {} }, _meta: { coda: { waits: {}, remote: {} } } }, authMethods: [] };
|
|
227
284
|
}
|
|
228
285
|
if (method === 'session/new' || method === 'session/load') return this.open(p, method === 'session/load');
|
|
229
|
-
if (!['session/set_config_option', 'session/prompt', 'session/cancel', '_coda/remote'].includes(method)) throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
286
|
+
if (!['session/set_mode', 'session/set_config_option', 'session/prompt', 'session/cancel', '_coda/remote'].includes(method)) throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
230
287
|
const s = this.session(p.sessionId);
|
|
288
|
+
if (method === 'session/set_mode') {
|
|
289
|
+
if (!MODES.some(mode => mode.id === p.modeId)) fail('未知的派出 Agent 档位');
|
|
290
|
+
// Coda persists and reapplies the selection, and delegates child approval
|
|
291
|
+
// modes using the manifest. It does not change this adapter's tool policy.
|
|
292
|
+
s.mode = p.modeId;
|
|
293
|
+
this.update(s, { sessionUpdate: 'current_mode_update', currentModeId: s.mode });
|
|
294
|
+
return {};
|
|
295
|
+
}
|
|
231
296
|
if (method === 'session/set_config_option') {
|
|
232
297
|
if (p.configId === 'model' && p.value === 'grok-bot') return { configOptions: this.configs(s) };
|
|
233
298
|
if (p.configId !== 'bot' || typeof p.value !== 'string' || (p.value !== '' && !Object.hasOwn(this.settings['grok-bot.bots'] ?? {}, p.value))) fail('未知的 Bot 配置');
|
|
@@ -254,6 +319,11 @@ export class Adapter {
|
|
|
254
319
|
return { sessionId: s.id, bot: s.saved.bot, instructions: s.instructions };
|
|
255
320
|
}
|
|
256
321
|
if (!s.saved.bound) fail('Bot 尚未完成绑定,请先运行 bind');
|
|
322
|
+
if (p.method === 'input') {
|
|
323
|
+
if (typeof args.turn !== 'string' || !args.turn) fail('input 必须带 Webhook 的 turn');
|
|
324
|
+
if (!s.input || s.input.turn !== args.turn) fail('本轮输入不存在或已被下一轮替换;请使用最新 Webhook 的 turn');
|
|
325
|
+
return { ...s.input, instructions: s.instructions };
|
|
326
|
+
}
|
|
257
327
|
if (p.method === 'say') {
|
|
258
328
|
if (typeof args.text !== 'string' || !args.text.trim()) fail('say 需要非空 text');
|
|
259
329
|
const turn = this.active(s, args.turn, true);
|
package/agent/setup.mjs
CHANGED
|
@@ -19,6 +19,6 @@ net 若打印 Tailscale 登录链接,请让我完成登录,把云电脑加
|
|
|
19
19
|
|
|
20
20
|
在 Bot 设置里添加命令启动的自定义 MCP 服务:\`node /workspace/.coda-bot/coda-bot.mjs mcp\`。同账号多个 Bot 共用云电脑时,每个 Bot 必须在命令末尾追加自己的会话 ID:\`${sessionId}\`,避免串用绑定。
|
|
21
21
|
读取并安装 /workspace/.coda-bot/SKILL.md 为此 Bot 的 Skill;bind 输出的会话指令也要遵守。
|
|
22
|
-
建立两个 Routine:一个 Webhook
|
|
22
|
+
建立两个 Routine:一个 Webhook 触发,保留请求 JSON 的 sessionId 与 turn,每次先用 coda_input(turn) 取完整正文再做事,preview 只是预览,最后 coda_done;一个定时巡检,按 Skill 检查性能、稳定性与逻辑问题,没问题不说话。
|
|
23
23
|
把 Webhook 地址告诉我,key 经密钥卡片处理;我会把它们填到 Coda 插件 grok-bot.bots 表的同一行(行名为你的 Bot 名),并在会话「这是哪个 Bot」里选择这一行。Webhook 的 HTTP 200 只表示启动,最终答复须通过 coda_say / coda_done 送回 Coda。`;
|
|
24
24
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"id": "grok-bot",
|
|
4
4
|
"displayName": "Grok Bot",
|
|
5
5
|
"description": "把 Grok 云端 Bot 接到 Coda 的长期会话:Webhook 消息、定时巡检与用户审批。",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.4",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"engines": { "node": ">=24" },
|
|
9
9
|
"categories": ["Agent"],
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"agents": [{
|
|
20
20
|
"id": "grok-bot",
|
|
21
21
|
"displayName": "Grok Bot",
|
|
22
|
+
"modes": { "title": "派出的 Agent", "unset": "要你批准", "auto": "auto" },
|
|
22
23
|
"icon": { "light": "icons/grok.pdf", "template": true },
|
|
23
24
|
"command": "exec \"$CODA_NODE\" \"$CODA_PLUGIN_PATH/agent/entry.mjs\"",
|
|
24
25
|
"setupHint": "需要支持远端 Agent 入口的 Coda。先发一条消息获取绑定说明,再配置 Bot 的 Routine Webhook。"
|
package/remote/SKILL.md
CHANGED
|
@@ -11,10 +11,10 @@ description: 处理 Coda 会话的 Webhook 消息,安静巡检性能、稳定
|
|
|
11
11
|
|
|
12
12
|
## Webhook Routine
|
|
13
13
|
|
|
14
|
-
1. 读取收到的 JSON:`source: "coda"`、`sessionId`、`turn`、`
|
|
15
|
-
2.
|
|
14
|
+
1. 读取收到的 JSON:`source: "coda"`、`sessionId`、`turn`、`kind`、`preview`。确认 sessionId 是本 Bot 绑定的会话;不同的拒绝处理。
|
|
15
|
+
2. 每次 Webhook 先用 coda_input(参数 `{turn}`)取完整内容再做事,preview 只是预览,不能当成完整任务。返回的 text 是完整正文,kind 为 message、result 或 notification;子 Agent 结果和 Coda 通知也照常处理。同时应用返回的 instructions:这是 Coda 当前会话指令,替换此前 hello/input 得到的版本(为空也替换),然后再处理 text;恢复会话后无需重新绑定。指令不随 Webhook 重发。输入在本轮结束后仍可取,到下一轮开始即失效;失效时不要据旧预览继续执行。
|
|
16
16
|
3. 每次 coda_say、coda_done、Coda 工具与遥测调用都带这个 turn。它是本轮关联 ID,不是 Coda 工具的 turn 票据;后者由适配器取得。
|
|
17
|
-
4. 需要时用 coda_say
|
|
17
|
+
4. 需要时用 coda_say 简明汇报进展与结论;工具返回(含「已提交」回执)后,没有其他工作就调用 coda_done,不必继续等审批。Webhook 的 HTTP 200 只表示 Routine 开始,Bot 自己聊天里的答复不会自动显示到 Coda。
|
|
18
18
|
5. 提示轮次已结束/不匹配时,立即停止向该轮说话,不要省略 turn 重试,也不要创建新轮次绕过取消。云端任务未必随 Coda 取消停止,副作用是否已执行不确定时先只读核实,不能自动重发。
|
|
19
19
|
|
|
20
20
|
同一 Bot 的 Routine 不要重叠处理同一条 Webhook;记住已处理的 turn,不重复执行。Coda 没有保证云端恰好执行一次。
|
|
@@ -40,7 +40,9 @@ coda_machines 与 coda_machine_diagnostics 由 Coda 内置的「机器诊断」
|
|
|
40
40
|
发现问题时,先以 coda_say 简明报告「现象、证据、影响、建议」,首次省略 turn 开启自主轮次,保存返回的 turn,后续调用均带它。
|
|
41
41
|
需要修复时,先 coda_agents 找对应项目、机器、Agent,再 coda_spawn,必须带目标 project,不能在 Bot 所在的空目录开 Agent。在任务里写清证据位置、受影响版本/机器/时段、要重点关注的性能或逻辑路径、验证要求。
|
|
42
42
|
开 Agent、给别的会话发消息由 Coda 的授权卡片批准;拒绝后不能换手段执行。给自己开出的子会话补充属于已批准任务的延续,不另问;cancel 只能停自己开出的子会话,由 Coda 校验归属,不另问。不要为了绕过审批在云电脑上直接调用其他写接口。
|
|
43
|
-
|
|
43
|
+
想让派出的 Agent 不再要求用户逐次点审批,请用户把 Bot 会话的「派出的 Agent」设为「自动」;Coda 会给子 Agent 使用它自己的自动档位(如 Codex 自动按风险审核操作)。可切回「要你批准」。此档位只影响子 Agent,Bot 开 Agent、给别的会话发消息仍需 Coda 授权卡片批准,不允许绕过。
|
|
44
|
+
coda_spawn/coda_send 可能要等用户批准,等待期间 coda_status 看不到这个子会话,这不代表失败;不要重复调用,也不要改写同一任务再次派出。
|
|
45
|
+
收到「已提交,正在等用户在 Coda 卡片上批准」或其他等待授权的回执后,不重试、不轮询,没有其他工作就 coda_done;子会话结果会通过新的 Webhook 送回。已回执调用会登记为 Coda 后台活动;调用失败或 MCP 断连会终结原工具记录并在 Coda 会话里报告,不能把「已提交」当作执行成功。
|
|
44
46
|
|
|
45
47
|
把已报告问题的指纹、证据范围、相关会话/修复任务、最后状态按 sessionId 保存在 `/workspace/.coda-bot/memory/<sessionId>.json`。
|
|
46
48
|
同一问题无新证据不重复报告;恢复、明显恶化或修复结果值得更新时再说。遥测未配置、机器暂不可达时不编造健康结论,首次简明说明观测缺口并记住,避免每轮重复。
|
package/remote/coda-bot.mjs
CHANGED
|
@@ -17,9 +17,11 @@ const anyObject = { type: 'object', additionalProperties: true };
|
|
|
17
17
|
const proxy = (name, description, properties = {}, required = []) => ({ name: `coda_${name}`, description,
|
|
18
18
|
inputSchema: schema({ ...properties, turn }, required), method: 'tool', tool: name });
|
|
19
19
|
export const TOOLS = [
|
|
20
|
+
{ name: 'coda_input', description: '每次收到 Webhook 后先调用:用其中的 turn 获取本轮完整正文、kind 和当前 instructions;先用 instructions 替换此前会话指令(为空也替换),再处理正文。preview 只是预览。输入保留到下一轮开始,旧 turn 不可读取。',
|
|
21
|
+
inputSchema: schema({ turn }, ['turn']), method: 'input' },
|
|
20
22
|
{ name: 'coda_say', description: '向这个 Coda 会话汇报。处理 Webhook 必须带 turn;巡检发现问题才首次省略 turn,开启自主轮次,记下返回的 turn。没问题不调用。',
|
|
21
23
|
inputSchema: schema({ text: string('给用户看的简明正文'), turn }, ['text']), method: 'say' },
|
|
22
|
-
{ name: 'coda_done', description: '
|
|
24
|
+
{ name: 'coda_done', description: '本轮的最后一步:工具返回(含「已提交」回执)后调用,结束回复;收到回执无需继续等审批。Webhook 与自主轮次都必须收尾;轮次已结束时不要重试说话。',
|
|
23
25
|
inputSchema: schema({ turn }), method: 'done' },
|
|
24
26
|
proxy('machines', '读取空间各机器的在线、版本、更新和运行告警,不开启轮次。'),
|
|
25
27
|
proxy('machine_diagnostics', '读取指定机器的网络/客户端性能诊断。network 的 from/to 选择 Unix 毫秒时间范围的分钟样本(最多一天),否则 days/limit/client/verdict 筛选客户端探测;client-performance 只支持 days。',
|
|
@@ -35,12 +37,12 @@ export const TOOLS = [
|
|
|
35
37
|
proxy('read_thread', '读取会话消息与工具摘要;完整工具结果用 coda_read_tool_call。', { reference: string('会话 ID 或 coda://threads/ 地址'), before: { type: 'integer' }, limit: { type: 'integer' }, historyVersion: { type: 'integer' }, tools: { type: 'boolean' } }, ['reference']),
|
|
36
38
|
proxy('read_tool_call', '读取会话的一次工具调用及完整结果。', { reference: string('工具条目引用或 ID'), offset: { type: 'integer' }, maxBytes: { type: 'integer' } }, ['reference']),
|
|
37
39
|
proxy('agents', '列出可派遣 Agent、模型及项目,用于选择修复任务的目录。', { project: string('项目名称或 ID') }),
|
|
38
|
-
proxy('status', '
|
|
40
|
+
proxy('status', '读取本会话和子会话状态;等待批准的子会话可能不在 children 中,这不代表派活失败,不要据此重复 spawn/send 或轮询。副作用工具的 turn 票据由适配器内部取得,不要自行填写。', { childId: string('要读取结果的子会话') }),
|
|
39
41
|
proxy('workflow_status', '查看工作流运行的状态和失败证据。', { runId: string('工作流运行 ID 或 coda://workflow-runs/ 地址'), project: string('项目名称或 ID') }),
|
|
40
42
|
proxy('workflow_list', '列出项目的工作流程。', { project: string('项目名称或 ID') }),
|
|
41
|
-
proxy('spawn', '必须指定有代码的目标 project,开 Agent 由 Coda 的授权卡片批准;不要在 Bot
|
|
43
|
+
proxy('spawn', '必须指定有代码的目标 project,开 Agent 由 Coda 的授权卡片批准;不要在 Bot 的空目录开任务。可能先返回「已提交」;审批期间 status 看不到子会话不代表失败。结果作为新消息送回,不要重试、改写任务重复派出或轮询;无其他工作就 done。',
|
|
42
44
|
{ agent: string('Agent ID'), prompt: string('任务、证据、影响、重点检查项与验收条件'), title: string('任务标题'), project: string('必填:目标项目名称或 ID'), model: string('模型'), effort: string('推理强度'), options: anyObject }, ['agent', 'prompt', 'project']),
|
|
43
|
-
proxy('send', '给别的会话发消息由 Coda
|
|
45
|
+
proxy('send', '给别的会话发消息由 Coda 的授权卡片批准;给自己开出的子会话补充属于已批准任务的延续,不另问。可能先返回「已提交」;审批期间 status 看不到子会话不代表失败。结果作为新消息送回,不要重试或轮询;无其他工作就 done。', { childId: string('目标会话 ID 或引用'), prompt: string('消息正文') }, ['childId', 'prompt']),
|
|
44
46
|
proxy('cancel', '只能停止自己开出的子会话,由 Coda 校验归属,不另问。', { childId: string('自己开出的子会话 ID 或引用') }, ['childId']),
|
|
45
47
|
];
|
|
46
48
|
|