@coda-console/grok-bot 0.1.6 → 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 +43 -48
- 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 +300 -97
- package/agent/setup.mjs +4 -3
- package/agent/watch.mjs +143 -0
- package/package.json +28 -28
- package/remote/SKILL.md +23 -26
- package/remote/coda-bot.mjs +9 -18
package/agent/session.mjs
CHANGED
|
@@ -3,21 +3,17 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { CodaMcp } from './mcp.mjs';
|
|
5
5
|
import { bindingGuide } from './setup.mjs';
|
|
6
|
+
import { NativeAuth } from './auth.mjs';
|
|
7
|
+
import { NativeWatch } from './watch.mjs';
|
|
8
|
+
import { RoutineWatch } from './routines.mjs';
|
|
6
9
|
|
|
7
10
|
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
|
-
];
|
|
12
11
|
const READ = new Set(['read_thread', 'read_tool_call', 'agents', 'status', 'workflow_status', 'workflow_list', 'machines', 'machine_diagnostics']);
|
|
13
|
-
const WRITE = new Set(['
|
|
12
|
+
const WRITE = new Set(['new_session', 'send']);
|
|
14
13
|
const DIAGNOSTICS = new Set(['machines', 'machine_diagnostics']);
|
|
15
14
|
const fail = message => { throw Object.assign(new Error(message), { code: -32602 }); };
|
|
16
15
|
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
-
|
|
18
|
-
if (/^\s*(?:Child session completed:|\[Coda\] (?:以下子会话(?:已结束|交回结果)|以下会话回复|你请求(?:派到其他项目的子会话|发给其他会话的消息)))/u.test(text)) return 'result';
|
|
19
|
-
return /^\s*\[Coda\]/u.test(text) ? 'notification' : 'message';
|
|
20
|
-
}
|
|
16
|
+
export const SETTLEMENT_GRACE_MS = 5000;
|
|
21
17
|
export const SUBMISSION_WAIT_MS = 20_000;
|
|
22
18
|
function canonical(value) {
|
|
23
19
|
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
@@ -46,10 +42,12 @@ function url(value) {
|
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
export class Adapter {
|
|
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) }) {
|
|
45
|
+
constructor({ rpc, env = process.env, fetcher = fetch, clock = { setTimeout, clearTimeout, now: Date.now }, submissionWaitMs = SUBMISSION_WAIT_MS, log = text => console.error(text), mcpFactory = (server, cwd) => new CodaMcp(server, cwd) }) {
|
|
50
46
|
Object.assign(this, { rpc, env, fetcher, clock, submissionWaitMs, log, mcpFactory });
|
|
51
47
|
this.settings = JSON.parse(env.CODA_PLUGIN_SETTINGS || '{}');
|
|
52
|
-
this.
|
|
48
|
+
this.now = () => this.clock.now ? this.clock.now() : Date.now();
|
|
49
|
+
this.auth = new NativeAuth({ root: env.CODA_PLUGIN_STATE, fetcher, clock });
|
|
50
|
+
this.bots = [];
|
|
53
51
|
this.sessions = new Map();
|
|
54
52
|
this.timeout = duration(this.settings['grok-bot.turnTimeout']);
|
|
55
53
|
}
|
|
@@ -65,11 +63,16 @@ export class Adapter {
|
|
|
65
63
|
}
|
|
66
64
|
session(id) { return this.sessions.get(id) ?? fail('会话尚未加载'); }
|
|
67
65
|
configs(s) {
|
|
68
|
-
const bots = Object.keys(this.settings['grok-bot.bots'] ?? {});
|
|
69
66
|
return [MODEL, { id: 'bot', name: '这是哪个 Bot', type: 'select', currentValue: s.saved.bot,
|
|
70
|
-
options: [{ value: '', name: '请选择 Bot' }, ...
|
|
67
|
+
options: [{ value: '', name: '请选择 Bot(先发送 /login 登录)' }, ...this.bots.map(bot => ({ value: bot.agentId, name: `${bot.name || bot.agentId} (${bot.agentId})` })),
|
|
68
|
+
...(!this.bots.some(b => b.agentId === s.saved.bot) && s.saved.bot ? [{ value: s.saved.bot, name: s.saved.bot }] : [])] }];
|
|
69
|
+
}
|
|
70
|
+
wait(s, state) { if (this.waits) this.rpc.notify('_coda/wait', { sessionId: s.id, waitId: s.subscriptionId ?? 'grok-native', kind: 'task', title: 'Grok 主会话订阅', state }); }
|
|
71
|
+
async refreshBots() {
|
|
72
|
+
if (!this.auth.client) return;
|
|
73
|
+
const result = await this.auth.client.request('ListGrokBotAgents', {});
|
|
74
|
+
this.bots = result.agents ?? [];
|
|
71
75
|
}
|
|
72
|
-
wait(s, state) { if (this.waits) this.rpc.notify('_coda/wait', { sessionId: s.id, waitId: 'grok-bot-routines', kind: 'timer', recurring: true, title: 'Grok Bot 的定时任务', state }); }
|
|
73
76
|
update(s, update) { this.rpc.notify('session/update', { sessionId: s.id, update }); }
|
|
74
77
|
say(s, text) { this.update(s, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }); }
|
|
75
78
|
start(s, autonomous) {
|
|
@@ -77,12 +80,13 @@ export class Adapter {
|
|
|
77
80
|
if (autonomous && !this.waits) fail('Coda 未声明 waits,不能开启自主轮次');
|
|
78
81
|
const turn = { id: randomUUID(), autonomous, controller: new AbortController(), tools: new Map() };
|
|
79
82
|
turn.result = new Promise(resolve => { turn.resolve = resolve; });
|
|
80
|
-
|
|
83
|
+
|
|
81
84
|
s.turn = turn;
|
|
82
85
|
if (autonomous) this.wait(s, 'handling');
|
|
83
86
|
turn.timer = this.clock.setTimeout(() => {
|
|
84
87
|
if (s.turn !== turn) return;
|
|
85
|
-
|
|
88
|
+
turn.failed = true;
|
|
89
|
+
this.say(s, `等待 Grok Bot 超过 ${this.settings['grok-bot.turnTimeout'] ?? 'PT30M'},本轮已结束;云端任务未必停止。之后收到的原生消息将标为补充消息。`);
|
|
86
90
|
this.finish(s, 'end_turn');
|
|
87
91
|
}, this.timeout);
|
|
88
92
|
return turn;
|
|
@@ -93,15 +97,16 @@ export class Adapter {
|
|
|
93
97
|
for (const [toolCallId, call] of turn.tools) if (!call.background)
|
|
94
98
|
this.update(s, { sessionUpdate: 'tool_call_update', toolCallId, status: 'failed', rawOutput: '本轮已结束,远端操作结果未确认' });
|
|
95
99
|
this.clock.clearTimeout(turn.timer);
|
|
100
|
+
this.clock.clearTimeout(turn.settlementTimer);
|
|
101
|
+
if (turn.activityId) this.update(s, { sessionUpdate: 'tool_call_update', toolCallId: turn.activityId, status: stopReason === 'cancelled' || turn.failed ? 'failed' : 'completed' });
|
|
102
|
+
if (turn.nativeId) s.closedRuns.add(turn.nativeId);
|
|
96
103
|
s.turn = null;
|
|
97
104
|
turn.controller.abort();
|
|
98
|
-
if (turn.autonomous) this.wait(s, 'waiting');
|
|
105
|
+
if (turn.autonomous) this.wait(s, s.watch ? 'waiting' : 'done');
|
|
99
106
|
turn.resolve({ stopReason });
|
|
100
107
|
}
|
|
101
|
-
active(s,
|
|
102
|
-
if (
|
|
103
|
-
// Webhook runs must carry their ID, including after a cancellation and a new prompt.
|
|
104
|
-
if (turn === undefined && s.turn && !s.turn.autonomous) fail('处理 Coda 消息必须带 Webhook 中的 turn');
|
|
108
|
+
active(s, id, create = false) {
|
|
109
|
+
if (id !== undefined && id !== s.turn?.id) fail('轮次已结束或不匹配');
|
|
105
110
|
if (!s.turn && create) return this.start(s, true);
|
|
106
111
|
return s.turn;
|
|
107
112
|
}
|
|
@@ -115,13 +120,13 @@ export class Adapter {
|
|
|
115
120
|
let saved = { bot: '', bound: false };
|
|
116
121
|
if (load) {
|
|
117
122
|
try {
|
|
118
|
-
|
|
119
|
-
saved
|
|
123
|
+
saved = JSON.parse(readFileSync(this.file(id), 'utf8'));
|
|
124
|
+
if (typeof saved.bot !== 'string' || typeof saved.bound !== 'boolean') fail('无效的 Bot 会话状态');
|
|
125
|
+
delete saved.instructions;
|
|
120
126
|
}
|
|
121
127
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
122
128
|
}
|
|
123
|
-
const
|
|
124
|
-
const s = { id, saved, instructions, mode: 'manual', diagnosticsEnabled: mcpServers.some(server => server.name === 'diagnostics'), clients: [], routes: new Map(), unavailable: new Map(), turn: null };
|
|
129
|
+
const s = { id, saved, sent: new Map(), closedRuns: new Set(), texts: new Map(), widgets: new Set(), diagnosticsEnabled: mcpServers.some(server => server.name === 'diagnostics'), clients: [], routes: new Map(), unavailable: new Map(), turn: null };
|
|
125
130
|
// Coda and the optional built-in diagnostics plugin are separate stdio MCP servers. Discover
|
|
126
131
|
// both at load time; never treat a tool name returned by a server as authority
|
|
127
132
|
// to expand the whitelist, or fall back to coda for absent diagnostics tools.
|
|
@@ -148,42 +153,254 @@ export class Adapter {
|
|
|
148
153
|
s.routes.set(tool.name, catalog.client);
|
|
149
154
|
}
|
|
150
155
|
this.sessions.set(id, s);
|
|
151
|
-
this.
|
|
152
|
-
|
|
156
|
+
if (this.auth.client) {
|
|
157
|
+
try { await this.refreshBots(); if (saved.bot) await this.attach(s); }
|
|
158
|
+
catch (error) { s.setupError = error.message; this.log(error.message); }
|
|
159
|
+
}
|
|
160
|
+
return { ...(load ? {} : { sessionId: id }), configOptions: this.configs(s) };
|
|
153
161
|
}
|
|
154
162
|
async prompt(s, prompt) {
|
|
155
|
-
if (!Array.isArray(prompt) || prompt.some(
|
|
156
|
-
const turn = this.start(s, false);
|
|
163
|
+
if (!Array.isArray(prompt) || prompt.some(b => b.type !== 'text' || typeof b.text !== 'string')) fail('Grok Bot 目前只接受文本');
|
|
157
164
|
const text = prompt.map(b => b.text).join('\n');
|
|
158
|
-
|
|
159
|
-
|
|
165
|
+
if (!text.trim()) fail('消息不能为空');
|
|
166
|
+
const turn = this.start(s, false);
|
|
160
167
|
void (async () => {
|
|
161
|
-
if (!
|
|
162
|
-
|
|
168
|
+
if (!this.auth.client || text.trim() === '/login') {
|
|
169
|
+
this.beginLogin(s); this.finish(s, 'end_turn'); return;
|
|
170
|
+
}
|
|
171
|
+
if (text.trim() === '/bind') {
|
|
172
|
+
if (!this.remote) throw new Error('请升级到支持远端 Agent 入口的 Coda');
|
|
163
173
|
const code = await this.rpc.request('_coda/remote/code', { sessionId: s.id }, { signal: turn.controller.signal });
|
|
164
|
-
if (s.turn
|
|
165
|
-
this.say(s, bindingGuide(s.id, code));
|
|
166
|
-
this.finish(s, 'end_turn');
|
|
174
|
+
if (s.turn === turn) { this.say(s, bindingGuide(s.id, code)); this.finish(s, 'end_turn'); }
|
|
167
175
|
return;
|
|
168
176
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
if (!key) throw new Error('这个 Bot 的 Webhook key 为空');
|
|
174
|
-
const characters = [...text];
|
|
175
|
-
const preview = characters.slice(0, 500).join('') + (characters.length > 500 ? '……(完整内容用 coda_input 取)' : '');
|
|
176
|
-
const response = await this.fetcher(url(bot.webhook), { method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
177
|
-
body: JSON.stringify({ source: 'coda', sessionId: s.id, turn: turn.id, kind: s.input.kind, preview }), signal: turn.controller.signal });
|
|
178
|
-
if (!response.ok) throw new Error(`Grok Webhook 返回 HTTP ${response.status};未确认云端是否开始处理,不自动重发。`);
|
|
179
|
-
await response.body?.cancel();
|
|
180
|
-
})().catch(error => {
|
|
181
|
-
if (s.turn !== turn) return;
|
|
182
|
-
this.say(s, `消息未能完成:${error.message}`);
|
|
183
|
-
this.finish(s, 'end_turn');
|
|
184
|
-
});
|
|
177
|
+
if (!s.saved.bot) { this.say(s, '请在「这是哪个 Bot」中按名字或 ID 选择 Bot,再发送消息。'); this.finish(s, 'end_turn'); return; }
|
|
178
|
+
if (!s.watch) await this.attach(s);
|
|
179
|
+
if (s.turn === turn) await this.sendNative(s, turn, text);
|
|
180
|
+
})().catch(error => { if (s.turn === turn) { this.say(s, `消息未能完成:${error.message};未自动重发。认证失效请发送 /login。`); this.finish(s, 'end_turn'); } });
|
|
185
181
|
return turn.result;
|
|
186
182
|
}
|
|
183
|
+
beginLogin(s) {
|
|
184
|
+
const login = this.auth.login();
|
|
185
|
+
this.say(s, `请打开官方登录链接:${login.url}\n手机可能停在「Completing sign-in」,请再点一次 Continue。登录完成后会自动显示 Bot 列表。`);
|
|
186
|
+
if (this.loginTask) return;
|
|
187
|
+
const waitId = `grok-login-${randomUUID()}`;
|
|
188
|
+
this.rpc.notify('_coda/wait', { sessionId: s.id, waitId, kind: 'task', state: 'waiting', title: '等待 Grok 官方登录' });
|
|
189
|
+
this.loginTask = login.promise.then(async () => {
|
|
190
|
+
await this.refreshBots();
|
|
191
|
+
for (const session of this.sessions.values()) {
|
|
192
|
+
this.update(session, { sessionUpdate: 'config_option_update', configOptions: this.configs(session) });
|
|
193
|
+
if (session.saved.bot) { session.routines?.close(); session.watch?.close(); this.wait(session, 'done'); session.watch = null; await this.attach(session); }
|
|
194
|
+
}
|
|
195
|
+
this.rpc.notify('_coda/wait', { sessionId: s.id, waitId, state: 'handling' });
|
|
196
|
+
this.say(s, 'Grok 登录成功,请在「这是哪个 Bot」选择 Bot。');
|
|
197
|
+
this.rpc.notify('_coda/wait', { sessionId: s.id, waitId, state: 'done' });
|
|
198
|
+
}).catch(error => {
|
|
199
|
+
if (this.closed) return;
|
|
200
|
+
this.rpc.notify('_coda/wait', { sessionId: s.id, waitId, state: 'handling' });
|
|
201
|
+
this.say(s, `登录未完成:${error.message};请发送 /login 重试。`);
|
|
202
|
+
this.rpc.notify('_coda/wait', { sessionId: s.id, waitId, state: 'failed' });
|
|
203
|
+
}).finally(() => { this.loginTask = null; });
|
|
204
|
+
}
|
|
205
|
+
nativeNotice(s, text) {
|
|
206
|
+
const own = !s.turn; if (own) this.start(s, true);
|
|
207
|
+
this.say(s, text);
|
|
208
|
+
if (own) this.finish(s, 'end_turn');
|
|
209
|
+
}
|
|
210
|
+
healthCallbacks(s, source) {
|
|
211
|
+
return {
|
|
212
|
+
log: message => this.log(JSON.stringify({ sessionId: s.id, source, message })),
|
|
213
|
+
error: error => this.nativeNotice(s, `Grok ${source}持续失败:${error.message}。认证失效请发送 /login;其他断线按游标重连,不重发消息。`),
|
|
214
|
+
recovered: () => this.nativeNotice(s, `Grok ${source}已恢复。`),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async attach(s) {
|
|
218
|
+
if (!this.waits) throw new Error('当前 Coda 不支持 waits,无法保持原生订阅与自主轮次');
|
|
219
|
+
s.subscriptionId = `grok-native-${randomUUID()}`;
|
|
220
|
+
this.wait(s, 'waiting');
|
|
221
|
+
const watch = new NativeWatch({ client: this.auth.client, bot: s.saved.bot, saved: s.saved, clock: this.clock,
|
|
222
|
+
commit: () => { if (s.watch === watch) this.save(s); }, event: event => { if (s.watch === watch) return this.nativeEvent(s, event); },
|
|
223
|
+
...this.healthCallbacks(s, '主会话订阅') });
|
|
224
|
+
s.watch = watch;
|
|
225
|
+
s.routines?.close();
|
|
226
|
+
s.routines = new RoutineWatch({ client: this.auth.client, bot: s.saved.bot, saved: s.saved, clock: this.clock,
|
|
227
|
+
commit: () => this.save(s), notify: event => this.rpc.notify('_coda/wait', { sessionId: s.id, ...event }),
|
|
228
|
+
...this.healthCallbacks(s, 'Routine 记录') });
|
|
229
|
+
try { await watch.start(); void s.routines.hint(); }
|
|
230
|
+
catch (error) { watch.close(); s.watch = null; this.wait(s, 'failed'); throw error; }
|
|
231
|
+
}
|
|
232
|
+
async sendNative(s, turn, text) {
|
|
233
|
+
turn.messageId = randomUUID();
|
|
234
|
+
s.sent.set(turn.messageId, turn.id);
|
|
235
|
+
const result = await this.auth.client.request('SendGrokBotUserMessage', { agentId: s.saved.bot, sessionId: '', messageId: turn.messageId, text,
|
|
236
|
+
sentAtMs: String(this.now()), source: 1, initiator: 1, sendSource: 1 }, { signal: turn.controller.signal });
|
|
237
|
+
if (!result.dispatched) throw new Error('Grok 未确认主会话开始执行');
|
|
238
|
+
}
|
|
239
|
+
async nativeEvent(s, event) {
|
|
240
|
+
if (this.closed) return;
|
|
241
|
+
if (event.connected) { if (s.connected) void s.routines.hint(); s.connected = true; return; }
|
|
242
|
+
if (event.routinesChanged) { void s.routines.hint(); return; }
|
|
243
|
+
if (event.entry) {
|
|
244
|
+
const { row, body } = event.entry;
|
|
245
|
+
if (body.kind === 'message' && body.role === 'user') {
|
|
246
|
+
if (s.sent.has(body.clientNonce)) {
|
|
247
|
+
if (s.sent.get(body.clientNonce) === s.turn?.id) s.turn.nativeId = body.requestId;
|
|
248
|
+
else s.closedRuns.add(body.requestId);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
if (s.turn?.settlement && s.turn.nativeId !== body.requestId) this.finish(s, 'end_turn');
|
|
252
|
+
if (!s.turn) this.start(s, true);
|
|
253
|
+
if (!s.turn.nativeId) s.turn.nativeId = body.requestId;
|
|
254
|
+
this.update(s, { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: body.content ?? '' } });
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (body.kind !== 'send-message') return;
|
|
259
|
+
if (body.message?.type === 'widget' && (body.respondedValue !== undefined || body.widgetSkipped)) return;
|
|
260
|
+
const late = s.closedRuns.has(body.requestId);
|
|
261
|
+
if (!late && s.turn?.autonomous && s.turn.hasReply && s.turn.nativeId && s.turn.nativeId !== body.requestId) this.finish(s, 'end_turn');
|
|
262
|
+
if (!s.turn) {
|
|
263
|
+
this.start(s, true);
|
|
264
|
+
if (late) { this.say(s, 'Grok 上一轮补充消息:'); s.turn.settlement = { turnId: body.requestId, outcome: 'success', settledAtMs: String(this.now()) }; }
|
|
265
|
+
}
|
|
266
|
+
const turn = s.turn;
|
|
267
|
+
if (!turn.nativeId && !(late && turn.messageId)) turn.nativeId = body.requestId;
|
|
268
|
+
// Preserve late native output as an explicit supplement, never silently drop it.
|
|
269
|
+
if (body.requestId && turn.nativeId !== body.requestId) this.say(s, 'Grok 上一轮补充消息:');
|
|
270
|
+
if (turn.nativeId === body.requestId) {
|
|
271
|
+
turn.hasReply = true;
|
|
272
|
+
if (s.lastSettlement?.turnId === body.requestId)
|
|
273
|
+
(turn.pendingSettlements ??= new Map()).set(body.requestId, s.lastSettlement);
|
|
274
|
+
}
|
|
275
|
+
if (body.message?.type === 'text') {
|
|
276
|
+
const text = body.message.content ?? '', previous = s.texts.get(row.entryId) ?? '';
|
|
277
|
+
if (text !== previous) this.say(s, text.startsWith(previous) ? text.slice(previous.length) : `\nGrok 修订消息:${text}`);
|
|
278
|
+
s.texts.set(row.entryId, text);
|
|
279
|
+
} else if (body.message?.type === 'widget') {
|
|
280
|
+
if (!s.widgets.has(row.entryId)) { s.widgets.add(row.entryId); void this.widget(s, turn, row.entryId, body.message.widget); }
|
|
281
|
+
} else this.say(s, `Grok 返回未支持的消息类型 ${body.message?.type ?? 'unknown'},请在 Grok 客户端查看。`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (event.live) {
|
|
285
|
+
const live = event.live;
|
|
286
|
+
if (live.hasRunningSubagents || s.hadSubagents) void s.routines.hint(!!live.hasRunningSubagents);
|
|
287
|
+
s.hadSubagents = !!live.hasRunningSubagents;
|
|
288
|
+
if (live.isRunning && !s.turn) this.start(s, true);
|
|
289
|
+
if (live.isRunning && s.turn) {
|
|
290
|
+
s.turn.nativeRunning = true;
|
|
291
|
+
if (live.activity?.kind === 'thinking' && !s.turn.thinking) {
|
|
292
|
+
s.turn.thinking = true;
|
|
293
|
+
this.update(s, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Grok 正在思考(原生状态,未提供思考正文)' } });
|
|
294
|
+
}
|
|
295
|
+
if (live.activity?.kind === 'tool') {
|
|
296
|
+
const title = live.activity.detail || live.activity.tool || 'Grok 活动';
|
|
297
|
+
if (title !== s.turn.activityTitle) {
|
|
298
|
+
if (s.turn.activityId) this.update(s, { sessionUpdate: 'tool_call_update', toolCallId: s.turn.activityId, status: 'completed' });
|
|
299
|
+
s.turn.activityTitle = title; s.turn.activityId = randomUUID();
|
|
300
|
+
this.update(s, { sessionUpdate: 'tool_call', toolCallId: s.turn.activityId, title, kind: 'other', status: 'in_progress' });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (live.awaiting) {
|
|
305
|
+
this.log(JSON.stringify({ sessionId: s.id, nativeAwaiting: live.awaiting }));
|
|
306
|
+
if (!s.awaiting) { if (!s.turn) this.start(s, true); this.say(s, 'Grok 正在等待原生审批,请到 Grok 客户端处理。'); }
|
|
307
|
+
}
|
|
308
|
+
s.awaiting = !!live.awaiting;
|
|
309
|
+
const settlement = live.lastTurnSettlement;
|
|
310
|
+
if (settlement && !live.isRunning) s.lastSettlement = settlement;
|
|
311
|
+
if (!live.isRunning && settlement && !s.closedRuns.has(settlement.turnId) && !(event.snapshot && !s.turn)) {
|
|
312
|
+
if (!s.turn) this.start(s, true);
|
|
313
|
+
// An outgoing prompt needs its echo/reply to identify the native run.
|
|
314
|
+
// Autonomous runs have no outgoing message and may settle silently.
|
|
315
|
+
if (!s.turn.nativeId && s.turn.autonomous) s.turn.nativeId = settlement.turnId;
|
|
316
|
+
if (s.turn.nativeId === settlement.turnId) this.applySettlement(s, settlement);
|
|
317
|
+
else if (!s.turn.nativeId) (s.turn.pendingSettlements ??= new Map()).set(settlement.turnId, settlement);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (event.failed) {
|
|
321
|
+
const id = event.failed.turnId;
|
|
322
|
+
if (id && s.closedRuns.has(id)) {
|
|
323
|
+
this.log(JSON.stringify({ sessionId: s.id, closedRunFailure: event.failed }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (s.turn && (!id || s.turn.nativeId !== id)) {
|
|
327
|
+
if (id && !s.turn.nativeId) {
|
|
328
|
+
// A Coda prompt is not associated with a native turn until its echo
|
|
329
|
+
// (or reply) arrives. Retain failures until that association is known.
|
|
330
|
+
(s.turn.pendingFailures ??= new Map()).set(id, event.failed);
|
|
331
|
+
this.log(JSON.stringify({ sessionId: s.id, unassociatedFailure: event.failed }));
|
|
332
|
+
} else this.say(s, `Grok 其他轮次 ${id ?? '(未提供 ID)'} 失败(${event.failed.code ?? 'unknown'})`);
|
|
333
|
+
} else {
|
|
334
|
+
if (!s.turn) { this.start(s, true); s.turn.nativeId = id; }
|
|
335
|
+
s.turn.failed = true;
|
|
336
|
+
this.say(s, `Grok 运行失败(${event.failed.code ?? 'unknown'})`);
|
|
337
|
+
this.finish(s, 'end_turn');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (s.turn?.nativeId && s.turn.pendingFailures) {
|
|
341
|
+
const failed = s.turn.pendingFailures.get(s.turn.nativeId);
|
|
342
|
+
delete s.turn.pendingFailures;
|
|
343
|
+
if (failed) return this.nativeEvent(s, { failed });
|
|
344
|
+
}
|
|
345
|
+
if (s.turn?.nativeId && s.turn.pendingSettlements) {
|
|
346
|
+
const settlement = s.turn.pendingSettlements.get(s.turn.nativeId);
|
|
347
|
+
delete s.turn.pendingSettlements;
|
|
348
|
+
if (settlement) this.applySettlement(s, settlement);
|
|
349
|
+
}
|
|
350
|
+
const turn = s.turn;
|
|
351
|
+
if (turn?.settlement?.outcome === 'success') {
|
|
352
|
+
const afterSettlement = event.heartbeat && BigInt(event.heartbeat.serverTimeMs ?? 0) > BigInt(turn.settlement.settledAtMs ?? 0);
|
|
353
|
+
if (turn.hasReply || afterSettlement) await this.completeSettled(s, turn, !!afterSettlement);
|
|
354
|
+
else this.settlementGrace(s, turn);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
applySettlement(s, settlement) {
|
|
358
|
+
s.turn.settlement = settlement;
|
|
359
|
+
s.turn.nativeRunning = false;
|
|
360
|
+
if (settlement.outcome === 'cancelled') this.finish(s, 'cancelled');
|
|
361
|
+
else if (settlement.outcome !== 'success') {
|
|
362
|
+
s.turn.failed = true;
|
|
363
|
+
this.say(s, `Grok 运行结束:${settlement.outcome}`);
|
|
364
|
+
this.finish(s, 'end_turn');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
settlementGrace(s, turn) {
|
|
368
|
+
if (turn.settlementTimer || s.turn !== turn) return;
|
|
369
|
+
turn.settlementTimer = this.clock.setTimeout(() => {
|
|
370
|
+
turn.settlementTimer = null;
|
|
371
|
+
void this.completeSettled(s, turn, true).catch(error => {
|
|
372
|
+
if (s.turn !== turn) return;
|
|
373
|
+
s.watch.health.failure(error);
|
|
374
|
+
this.settlementGrace(s, turn);
|
|
375
|
+
});
|
|
376
|
+
}, SETTLEMENT_GRACE_MS);
|
|
377
|
+
}
|
|
378
|
+
async completeSettled(s, turn, allowEmpty) {
|
|
379
|
+
if (s.turn !== turn || turn.reconciling || turn.widgetPending || (!turn.hasReply && !allowEmpty)) return;
|
|
380
|
+
turn.reconciling = true;
|
|
381
|
+
try {
|
|
382
|
+
await s.watch.reconcile();
|
|
383
|
+
if (s.turn === turn && !turn.widgetPending) this.finish(s, 'end_turn');
|
|
384
|
+
} finally { turn.reconciling = false; }
|
|
385
|
+
}
|
|
386
|
+
async widget(s, turn, entryId, widget) {
|
|
387
|
+
turn.widgetPending = (turn.widgetPending ?? 0) + 1;
|
|
388
|
+
try {
|
|
389
|
+
if (!Array.isArray(widget?.options) || !widget.options.length) throw new Error('widget 没有可选项,请到 Grok 客户端处理');
|
|
390
|
+
const options = widget.options.map((option, index) => ({ optionId: String(index), name: option.label ?? String(option.value), kind: 'allow_once' }));
|
|
391
|
+
const answer = await this.rpc.request('session/request_permission', { sessionId: s.id,
|
|
392
|
+
toolCall: { toolCallId: `widget:${entryId}`, title: widget.prompt || 'Grok 提问', status: 'pending', kind: 'other' }, options }, { signal: turn.controller.signal });
|
|
393
|
+
if (answer.outcome?.outcome !== 'selected') return;
|
|
394
|
+
const choice = options.findIndex(o => o.optionId === answer.outcome.optionId);
|
|
395
|
+
if (choice < 0) throw new Error('未知 widget 选项');
|
|
396
|
+
const result = await this.auth.client.request('RespondGrokBotWidget', { agentId: s.saved.bot, sessionId: '', entryId, value: widget.options[choice].value });
|
|
397
|
+
if (!result.accepted) throw new Error('Grok 未接受 widget 回答');
|
|
398
|
+
} catch (error) { if (s.turn === turn) this.say(s, `Grok 卡片未完成:${error.message}`); }
|
|
399
|
+
finally {
|
|
400
|
+
turn.widgetPending--;
|
|
401
|
+
if (turn.settlement?.outcome === 'success') this.settlementGrace(s, turn);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
187
404
|
async trace(s, name, args, operation, replyAfter) {
|
|
188
405
|
const turn = s.turn, toolCallId = randomUUID();
|
|
189
406
|
const background = replyAfter !== undefined && !!this.waits;
|
|
@@ -233,26 +450,29 @@ export class Adapter {
|
|
|
233
450
|
async tool(s, { name, arguments: args = {}, turn: id }) {
|
|
234
451
|
if (!READ.has(name) && !WRITE.has(name)) fail(`不允许代理工具:${name}`);
|
|
235
452
|
if (!object(args)) fail('arguments 必须是对象');
|
|
236
|
-
if (name === '
|
|
237
|
-
fail('
|
|
453
|
+
if (name === 'new_session' && (typeof args.project !== 'string' || !args.project.trim()))
|
|
454
|
+
fail('new_session 必须指定目标 project;Bot 所在的空目录没有代码,请选择要修复的项目');
|
|
238
455
|
this.provider(s, name);
|
|
456
|
+
const local = !s.turn;
|
|
239
457
|
const turn = this.active(s, id, WRITE.has(name));
|
|
240
458
|
const signal = turn?.controller.signal;
|
|
241
459
|
let forwarded = args;
|
|
242
460
|
if (WRITE.has(name)) {
|
|
243
|
-
// Coda owns authorization
|
|
244
|
-
//
|
|
461
|
+
// Coda owns authorization for new independent sessions and messages.
|
|
462
|
+
// new_session returns immediately after requesting approval.
|
|
245
463
|
const status = await this.trace(s, 'status', {}, () => this.provider(s, 'status').call('status', {}, signal));
|
|
246
464
|
if (status.isError) throw new Error('无法取得 Coda turn 票据');
|
|
247
465
|
const ticket = (status.structuredContent ?? JSON.parse(status.content.find(b => b.type === 'text').text)).turn;
|
|
248
466
|
if (typeof ticket !== 'string' || !ticket || s.turn !== turn) fail('没有有效的 Coda turn 票据');
|
|
249
467
|
forwarded = { ...args, turn: ticket, requestId: requestId(turn, name, args) };
|
|
250
468
|
}
|
|
251
|
-
const mayWaitForApproval = name === '
|
|
469
|
+
const mayWaitForApproval = name === 'send';
|
|
252
470
|
// Approval calls outlive a Coda turn and must not inherit its abort signal
|
|
253
471
|
// or the MCP client's default 60-second timeout (null disables both).
|
|
254
472
|
return this.trace(s, name, args, () => this.provider(s, name).call(name, forwarded, mayWaitForApproval ? null : signal),
|
|
255
|
-
mayWaitForApproval ? this.submissionWaitMs : undefined)
|
|
473
|
+
mayWaitForApproval ? this.submissionWaitMs : undefined).finally(() => {
|
|
474
|
+
if (local && turn && s.turn === turn && !turn.nativeRunning && !turn.nativeId) this.finish(s, 'end_turn');
|
|
475
|
+
});
|
|
256
476
|
}
|
|
257
477
|
provider(s, name) {
|
|
258
478
|
const client = s.routes.get(name);
|
|
@@ -285,30 +505,32 @@ export class Adapter {
|
|
|
285
505
|
return { protocolVersion: 1, agentCapabilities: { loadSession: true, sessionCapabilities: { systemPrompt: {} }, _meta: { coda: { waits: {}, remote: {} } } }, authMethods: [] };
|
|
286
506
|
}
|
|
287
507
|
if (method === 'session/new' || method === 'session/load') return this.open(p, method === 'session/load');
|
|
288
|
-
if (!['session/
|
|
508
|
+
if (!['session/set_config_option', 'session/prompt', 'session/cancel', '_coda/remote'].includes(method)) throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
289
509
|
const s = this.session(p.sessionId);
|
|
290
|
-
if (method === 'session/set_mode') {
|
|
291
|
-
if (!MODES.some(mode => mode.id === p.modeId)) fail('未知的派出 Agent 档位');
|
|
292
|
-
// Coda persists and reapplies the selection, and delegates child approval
|
|
293
|
-
// modes using the manifest. It does not change this adapter's tool policy.
|
|
294
|
-
s.mode = p.modeId;
|
|
295
|
-
this.update(s, { sessionUpdate: 'current_mode_update', currentModeId: s.mode });
|
|
296
|
-
return {};
|
|
297
|
-
}
|
|
298
510
|
if (method === 'session/set_config_option') {
|
|
299
511
|
if (p.configId === 'model' && p.value === 'grok-bot') return { configOptions: this.configs(s) };
|
|
300
|
-
if (p.configId !== 'bot' || typeof p.value !== 'string'
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
512
|
+
if (p.configId !== 'bot' || typeof p.value !== 'string') fail('未知的 Bot 配置');
|
|
513
|
+
await this.refreshBots();
|
|
514
|
+
const bot = this.bots.find(b => b.agentId === p.value || b.name === p.value);
|
|
515
|
+
if (p.value && !bot) fail('未找到 Bot,请先 /login 并刷新列表');
|
|
516
|
+
if (s.turn) fail('请先结束本轮再切换 Bot');
|
|
517
|
+
if ((bot?.agentId ?? '') === s.saved.bot) return { configOptions: this.configs(s) };
|
|
518
|
+
if (s.watch) { s.watch.close(); this.wait(s, 'done'); s.watch = null; }
|
|
519
|
+
s.routines?.close();
|
|
520
|
+
s.saved = { bot: bot?.agentId ?? '', bound: false };
|
|
521
|
+
s.closedRuns.clear(); s.texts.clear(); s.widgets.clear(); s.sent.clear();
|
|
522
|
+
s.connected = false; s.hadSubagents = false; s.lastSettlement = null; s.awaiting = false;
|
|
523
|
+
this.save(s);
|
|
524
|
+
if (bot) await this.attach(s);
|
|
307
525
|
return { configOptions: this.configs(s) };
|
|
308
526
|
}
|
|
309
527
|
if (method === 'session/prompt') return this.prompt(s, p.prompt);
|
|
310
528
|
if (method === 'session/cancel') {
|
|
311
|
-
|
|
529
|
+
const turn = s.turn;
|
|
530
|
+
if (turn && s.saved.bot && this.auth.client) {
|
|
531
|
+
await this.auth.client.request('InterruptGrokBotAgentRun', { agentId: s.saved.bot, sessionId: '' });
|
|
532
|
+
}
|
|
533
|
+
if (turn && s.turn === turn) this.finish(s, 'cancelled');
|
|
312
534
|
return {};
|
|
313
535
|
}
|
|
314
536
|
const args = p.params ?? {};
|
|
@@ -316,32 +538,13 @@ export class Adapter {
|
|
|
316
538
|
if (p.method === 'hello') {
|
|
317
539
|
s.saved.bound = true;
|
|
318
540
|
this.save(s);
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
return { sessionId: s.id, bot: s.saved.bot, instructions: s.instructions };
|
|
541
|
+
// Idempotent confirmation of the authenticated Coda tool binding.
|
|
542
|
+
return { sessionId: s.id, bot: s.saved.bot };
|
|
322
543
|
}
|
|
323
544
|
if (!s.saved.bound) fail('Bot 尚未完成绑定,请先运行 bind');
|
|
324
|
-
if (p.method === 'input') {
|
|
325
|
-
if (typeof args.turn !== 'string' || !args.turn) fail('input 必须带 Webhook 的 turn');
|
|
326
|
-
if (!s.input || s.input.turn !== args.turn) fail('本轮输入不存在或已被下一轮替换;请使用最新 Webhook 的 turn');
|
|
327
|
-
return { ...s.input, instructions: s.instructions };
|
|
328
|
-
}
|
|
329
|
-
if (p.method === 'say') {
|
|
330
|
-
if (typeof args.text !== 'string' || !args.text.trim()) fail('say 需要非空 text');
|
|
331
|
-
const turn = this.active(s, args.turn, true);
|
|
332
|
-
this.say(s, args.text);
|
|
333
|
-
return { turn: turn.id };
|
|
334
|
-
}
|
|
335
|
-
if (p.method === 'done') {
|
|
336
|
-
const turn = this.active(s, args.turn);
|
|
337
|
-
if (!turn) fail('没有正在进行的轮次');
|
|
338
|
-
if (turn.tools.size) fail('仍有工具调用或审批在进行,请等待结果再 done');
|
|
339
|
-
this.finish(s, 'end_turn');
|
|
340
|
-
return {};
|
|
341
|
-
}
|
|
342
545
|
if (p.method === 'tool') return this.tool(s, args);
|
|
343
546
|
if (p.method === 'telemetry') return this.telemetry(s, args);
|
|
344
547
|
fail(`未知的远端方法:${p.method}`);
|
|
345
548
|
}
|
|
346
|
-
close() { for (const s of this.sessions.values()) { this.finish(s, 'cancelled'); for (const client of s.clients) client.close(); } }
|
|
549
|
+
close() { this.closed = true; this.auth.close(); for (const s of this.sessions.values()) { s.routines?.close(); s.watch?.close(); this.finish(s, 'cancelled'); for (const client of s.clients) client.close(); } }
|
|
347
550
|
}
|
package/agent/setup.mjs
CHANGED
|
@@ -18,7 +18,8 @@ curl -fL ${quote(`${RAW}/SKILL.md`)} -o /workspace/.coda-bot/SKILL.md
|
|
|
18
18
|
net 若打印 Tailscale 登录链接,请让我完成登录,把云电脑加入能访问 Coda 后端的 tailnet;然后重新执行 bind。密钥需要时使用 Grok 的密钥卡片,不贴到聊天。
|
|
19
19
|
|
|
20
20
|
在 Bot 设置里添加命令启动的自定义 MCP 服务:\`node /workspace/.coda-bot/coda-bot.mjs mcp\`。同账号多个 Bot 共用云电脑时,每个 Bot 必须在命令末尾追加自己的会话 ID:\`${sessionId}\`,避免串用绑定。
|
|
21
|
-
读取并安装 /workspace/.coda-bot/SKILL.md 为此 Bot 的 Skill
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
读取并安装 /workspace/.coda-bot/SKILL.md 为此 Bot 的 Skill。
|
|
22
|
+
正常消息与回复走原生主会话,定时任务仍由 Grok Routine 执行。
|
|
23
|
+
升级旧 Bot:不再需要 Coda 消息(coda)Webhook Routine。保留 Coda 巡检(coda-2)定时 Routine;请先让我确认,再将旧指令中的 coda_input、coda_say、coda_done 改成「有发现就在对话里告诉用户」。更新脚本和 Skill 后重启 MCP。
|
|
24
|
+
这些工具只提供 Coda 数据和经卡片授权的操作,Bot 的正常回复会自动镜像到 Coda。`;
|
|
24
25
|
}
|