@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/agent/session.mjs CHANGED
@@ -3,6 +3,9 @@ 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
11
  const READ = new Set(['read_thread', 'read_tool_call', 'agents', 'status', 'workflow_status', 'workflow_list', 'machines', 'machine_diagnostics']);
@@ -10,11 +13,7 @@ const WRITE = new Set(['new_session', 'send']);
10
13
  const DIAGNOSTICS = new Set(['machines', 'machine_diagnostics']);
11
14
  const fail = message => { throw Object.assign(new Error(message), { code: -32602 }); };
12
15
  const object = value => value && typeof value === 'object' && !Array.isArray(value);
13
- function inputKind(text) {
14
- if (/^\s*(?:Child session completed:|\[Coda\] (?:以下子会话(?:已结束|交回结果)|以下会话回复|你请求(?:派到其他(?:项目|目录)的子会话|发给其他会话的消息)))/u.test(text)) return 'result';
15
- if (/^\s*(?:\[Coda\]\s*)?(?:已开出|用户拒绝了|未能开出|这张卡片已失效,没有开出)[::]/u.test(text)) return 'notification';
16
- return /^\s*\[Coda\]/u.test(text) ? 'notification' : 'message';
17
- }
16
+ export const SETTLEMENT_GRACE_MS = 5000;
18
17
  export const SUBMISSION_WAIT_MS = 20_000;
19
18
  function canonical(value) {
20
19
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
@@ -43,10 +42,12 @@ function url(value) {
43
42
  }
44
43
 
45
44
  export class Adapter {
46
- 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) }) {
47
46
  Object.assign(this, { rpc, env, fetcher, clock, submissionWaitMs, log, mcpFactory });
48
47
  this.settings = JSON.parse(env.CODA_PLUGIN_SETTINGS || '{}');
49
- this.secrets = JSON.parse(env.CODA_PLUGIN_SECRETS || '{}');
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 = [];
50
51
  this.sessions = new Map();
51
52
  this.timeout = duration(this.settings['grok-bot.turnTimeout']);
52
53
  }
@@ -62,11 +63,16 @@ export class Adapter {
62
63
  }
63
64
  session(id) { return this.sessions.get(id) ?? fail('会话尚未加载'); }
64
65
  configs(s) {
65
- const bots = Object.keys(this.settings['grok-bot.bots'] ?? {});
66
66
  return [MODEL, { id: 'bot', name: '这是哪个 Bot', type: 'select', currentValue: s.saved.bot,
67
- options: [{ value: '', name: '请选择 Bot' }, ...[...new Set([...bots, s.saved.bot].filter(Boolean))].map(value => ({ value, name: value }))] }];
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 ?? [];
68
75
  }
69
- 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 }); }
70
76
  update(s, update) { this.rpc.notify('session/update', { sessionId: s.id, update }); }
71
77
  say(s, text) { this.update(s, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }); }
72
78
  start(s, autonomous) {
@@ -74,12 +80,13 @@ export class Adapter {
74
80
  if (autonomous && !this.waits) fail('Coda 未声明 waits,不能开启自主轮次');
75
81
  const turn = { id: randomUUID(), autonomous, controller: new AbortController(), tools: new Map() };
76
82
  turn.result = new Promise(resolve => { turn.resolve = resolve; });
77
- s.input = null;
83
+
78
84
  s.turn = turn;
79
85
  if (autonomous) this.wait(s, 'handling');
80
86
  turn.timer = this.clock.setTimeout(() => {
81
87
  if (s.turn !== turn) return;
82
- this.say(s, `等待 Grok Bot 超过 ${this.settings['grok-bot.turnTimeout'] ?? 'PT30M'},本轮已结束;云端任务未必停止。迟到的本轮消息会被拒绝。`);
88
+ turn.failed = true;
89
+ this.say(s, `等待 Grok Bot 超过 ${this.settings['grok-bot.turnTimeout'] ?? 'PT30M'},本轮已结束;云端任务未必停止。之后收到的原生消息将标为补充消息。`);
83
90
  this.finish(s, 'end_turn');
84
91
  }, this.timeout);
85
92
  return turn;
@@ -90,15 +97,16 @@ export class Adapter {
90
97
  for (const [toolCallId, call] of turn.tools) if (!call.background)
91
98
  this.update(s, { sessionUpdate: 'tool_call_update', toolCallId, status: 'failed', rawOutput: '本轮已结束,远端操作结果未确认' });
92
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);
93
103
  s.turn = null;
94
104
  turn.controller.abort();
95
- if (turn.autonomous) this.wait(s, 'waiting');
105
+ if (turn.autonomous) this.wait(s, s.watch ? 'waiting' : 'done');
96
106
  turn.resolve({ stopReason });
97
107
  }
98
- active(s, turn, create = false) {
99
- if (turn !== undefined && (typeof turn !== 'string' || s.turn?.id !== turn)) fail('轮次已结束或不匹配;迟到的消息不再接收');
100
- // Webhook runs must carry their ID, including after a cancellation and a new prompt.
101
- 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('轮次已结束或不匹配');
102
110
  if (!s.turn && create) return this.start(s, true);
103
111
  return s.turn;
104
112
  }
@@ -112,13 +120,13 @@ export class Adapter {
112
120
  let saved = { bot: '', bound: false };
113
121
  if (load) {
114
122
  try {
115
- const { bot, bound } = JSON.parse(readFileSync(this.file(id), 'utf8'));
116
- saved = { bot, bound };
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;
117
126
  }
118
127
  catch (error) { if (error.code !== 'ENOENT') throw error; }
119
128
  }
120
- const instructions = systemPrompt?.content.map(b => b.text).join('\n') ?? '';
121
- const s = { id, saved, instructions, 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 };
122
130
  // Coda and the optional built-in diagnostics plugin are separate stdio MCP servers. Discover
123
131
  // both at load time; never treat a tool name returned by a server as authority
124
132
  // to expand the whitelist, or fall back to coda for absent diagnostics tools.
@@ -145,42 +153,254 @@ export class Adapter {
145
153
  s.routes.set(tool.name, catalog.client);
146
154
  }
147
155
  this.sessions.set(id, s);
148
- this.wait(s, 'waiting');
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
+ }
149
160
  return { ...(load ? {} : { sessionId: id }), configOptions: this.configs(s) };
150
161
  }
151
162
  async prompt(s, prompt) {
152
- if (!Array.isArray(prompt) || prompt.some(block => block.type !== 'text' || typeof block.text !== 'string')) fail('Grok Bot 目前只接受文本;请在文本里提供可读取的附件路径');
153
- const turn = this.start(s, false);
163
+ if (!Array.isArray(prompt) || prompt.some(b => b.type !== 'text' || typeof b.text !== 'string')) fail('Grok Bot 目前只接受文本');
154
164
  const text = prompt.map(b => b.text).join('\n');
155
- s.input = { turn: turn.id, kind: inputKind(text), text };
156
- // Return the turn promise immediately; cancellation also finishes while code/webhook is in flight.
165
+ if (!text.trim()) fail('消息不能为空');
166
+ const turn = this.start(s, false);
157
167
  void (async () => {
158
- if (!s.saved.bound || text.trim() === '/bind') {
159
- if (!this.remote) throw new Error('请先升级到支持远端 Agent 入口的 Coda,再绑定 Grok Bot。');
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');
160
173
  const code = await this.rpc.request('_coda/remote/code', { sessionId: s.id }, { signal: turn.controller.signal });
161
- if (s.turn !== turn) return;
162
- this.say(s, bindingGuide(s.id, code));
163
- this.finish(s, 'end_turn');
174
+ if (s.turn === turn) { this.say(s, bindingGuide(s.id, code)); this.finish(s, 'end_turn'); }
164
175
  return;
165
176
  }
166
- const bot = this.settings['grok-bot.bots']?.[s.saved.bot];
167
- const keyPath = this.secrets['grok-bot.bots']?.[s.saved.bot]?.key;
168
- if (!bot?.webhook || !keyPath) throw new Error('请在插件设置的 bots 表填写这个 Bot 的 Webhook 与密文 key,并在会话里选择它。');
169
- const key = readFileSync(keyPath, 'utf8').trim();
170
- if (!key) throw new Error('这个 Bot 的 Webhook key 为空');
171
- const characters = [...text];
172
- const preview = characters.slice(0, 500).join('') + (characters.length > 500 ? '……(完整内容用 coda_input 取)' : '');
173
- const response = await this.fetcher(url(bot.webhook), { method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
174
- body: JSON.stringify({ source: 'coda', sessionId: s.id, turn: turn.id, kind: s.input.kind, preview }), signal: turn.controller.signal });
175
- if (!response.ok) throw new Error(`Grok Webhook 返回 HTTP ${response.status};未确认云端是否开始处理,不自动重发。`);
176
- await response.body?.cancel();
177
- })().catch(error => {
178
- if (s.turn !== turn) return;
179
- this.say(s, `消息未能完成:${error.message}`);
180
- this.finish(s, 'end_turn');
181
- });
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'); } });
182
181
  return turn.result;
183
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
+ }
184
404
  async trace(s, name, args, operation, replyAfter) {
185
405
  const turn = s.turn, toolCallId = randomUUID();
186
406
  const background = replyAfter !== undefined && !!this.waits;
@@ -233,6 +453,7 @@ export class Adapter {
233
453
  if (name === 'new_session' && (typeof args.project !== 'string' || !args.project.trim()))
234
454
  fail('new_session 必须指定目标 project;Bot 所在的空目录没有代码,请选择要修复的项目');
235
455
  this.provider(s, name);
456
+ const local = !s.turn;
236
457
  const turn = this.active(s, id, WRITE.has(name));
237
458
  const signal = turn?.controller.signal;
238
459
  let forwarded = args;
@@ -249,7 +470,9 @@ export class Adapter {
249
470
  // Approval calls outlive a Coda turn and must not inherit its abort signal
250
471
  // or the MCP client's default 60-second timeout (null disables both).
251
472
  return this.trace(s, name, args, () => this.provider(s, name).call(name, forwarded, mayWaitForApproval ? null : signal),
252
- 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
+ });
253
476
  }
254
477
  provider(s, name) {
255
478
  const client = s.routes.get(name);
@@ -286,18 +509,28 @@ export class Adapter {
286
509
  const s = this.session(p.sessionId);
287
510
  if (method === 'session/set_config_option') {
288
511
  if (p.configId === 'model' && p.value === 'grok-bot') return { configOptions: this.configs(s) };
289
- if (p.configId !== 'bot' || typeof p.value !== 'string' || (p.value !== '' && !Object.hasOwn(this.settings['grok-bot.bots'] ?? {}, p.value))) fail('未知的 Bot 配置');
290
- if (p.value !== s.saved.bot) {
291
- if (s.turn) fail('请先结束本轮再切换 Bot');
292
- if (s.saved.bot) s.saved.bound = false;
293
- s.saved.bot = p.value;
294
- this.save(s);
295
- }
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);
296
525
  return { configOptions: this.configs(s) };
297
526
  }
298
527
  if (method === 'session/prompt') return this.prompt(s, p.prompt);
299
528
  if (method === 'session/cancel') {
300
- if (s.turn) { this.say(s, '本轮已取消;Grok 云端任务未必停止,迟到的本轮消息不会加入后续轮次。'); this.finish(s, 'cancelled'); }
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');
301
534
  return {};
302
535
  }
303
536
  const args = p.params ?? {};
@@ -305,32 +538,13 @@ export class Adapter {
305
538
  if (p.method === 'hello') {
306
539
  s.saved.bound = true;
307
540
  this.save(s);
308
- // The HTTP response can be lost after this request succeeds. Keep the
309
- // instructions available to every bind (including a different Bot).
310
- 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 };
311
543
  }
312
544
  if (!s.saved.bound) fail('Bot 尚未完成绑定,请先运行 bind');
313
- if (p.method === 'input') {
314
- if (typeof args.turn !== 'string' || !args.turn) fail('input 必须带 Webhook 的 turn');
315
- if (!s.input || s.input.turn !== args.turn) fail('本轮输入不存在或已被下一轮替换;请使用最新 Webhook 的 turn');
316
- return { ...s.input, instructions: s.instructions };
317
- }
318
- if (p.method === 'say') {
319
- if (typeof args.text !== 'string' || !args.text.trim()) fail('say 需要非空 text');
320
- const turn = this.active(s, args.turn, true);
321
- this.say(s, args.text);
322
- return { turn: turn.id };
323
- }
324
- if (p.method === 'done') {
325
- const turn = this.active(s, args.turn);
326
- if (!turn) fail('没有正在进行的轮次');
327
- if (turn.tools.size) fail('仍有工具调用或审批在进行,请等待结果再 done');
328
- this.finish(s, 'end_turn');
329
- return {};
330
- }
331
545
  if (p.method === 'tool') return this.tool(s, args);
332
546
  if (p.method === 'telemetry') return this.telemetry(s, args);
333
547
  fail(`未知的远端方法:${p.method}`);
334
548
  }
335
- 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(); } }
336
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;bind 输出的会话指令也要遵守。
22
- 建立两个 Routine:一个 Webhook 触发,保留请求 JSON 的 sessionId 与 turn,每次先用 coda_input(turn) 取完整正文再做事,preview 只是预览,最后 coda_done;一个定时巡检,按 Skill 检查性能、稳定性与逻辑问题,没问题不说话。
23
- 把 Webhook 地址告诉我,key 经密钥卡片处理;我会把它们填到 Coda 插件 grok-bot.bots 表的同一行(行名为你的 Bot 名),并在会话「这是哪个 Bot」里选择这一行。Webhook 的 HTTP 200 只表示启动,最终答复须通过 coda_say / coda_done 送回 Coda。`;
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
  }
@@ -0,0 +1,143 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { pause } from './auth.mjs';
3
+ import { ConnectionHealth, authenticationError } from './health.mjs';
4
+
5
+ export const TRANSCRIPT_CACHE_LIMIT = 2000;
6
+
7
+ export function decode(entry) {
8
+ if (entry.bodyOmitted) throw new Error('原生 transcript 正文被省略,无法交付;请在 Grok 客户端查看');
9
+ try { return JSON.parse(Buffer.from(entry.body, 'base64').toString('utf8')); }
10
+ catch { throw new Error('无法解析 Grok transcript 正文'); }
11
+ }
12
+ export const sequence = entries => entries.reduce((n, e) => BigInt(e.updatedSeq ?? 0) > BigInt(n) ? String(e.updatedSeq) : n, '0');
13
+ // A single connection owns a Bot's main transcript. The adapter serializes
14
+ // events; reconnects use the committed cursor, never resend user messages.
15
+ export class NativeWatch {
16
+ constructor({ client, bot, saved, clock, commit, event, error, recovered, log }) {
17
+ Object.assign(this, { client, bot, saved, clock, commit, event, error, recovered, log });
18
+ this.health = new ConnectionHealth({ error, recovered, log });
19
+ this.controller = new AbortController();
20
+ }
21
+ async history({ all = false } = {}) {
22
+ const entries = [];
23
+ let beforeSeq, generation;
24
+ for (;;) {
25
+ const page = await this.client.request('ListGrokBotTranscriptEntries', { agentId: this.bot, sessionId: '', limit: 1000,
26
+ ...(beforeSeq ? { beforeSeq, generation } : {}) }, { signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(30_000)]) });
27
+ if (generation !== undefined && generation !== (page.generation ?? 0)) throw new Error('Grok 历史在分页时已重置,重新连接后核对');
28
+ generation = page.generation ?? 0;
29
+ const rows = page.entries ?? [];
30
+ entries.push(...rows);
31
+ if (!all || rows.length < 1000) break;
32
+ const next = rows.reduce((n, r) => BigInt(r.seq) < BigInt(n) ? r.seq : n, rows[0].seq);
33
+ if (beforeSeq && BigInt(next) >= BigInt(beforeSeq)) throw new Error('Grok 历史分页游标没有前进');
34
+ beforeSeq = next;
35
+ }
36
+ return { entries, generation };
37
+ }
38
+ async start() {
39
+ if (!this.saved.stream) {
40
+ const h = await this.history();
41
+ // Existing Grok history is a baseline, not a new autonomous run.
42
+ this.saved.stream = { generation: h.generation ?? 0, afterUpdatedSeq: sequence(h.entries ?? []), seen: {}, hashes: {}, baselineAt: Date.now() };
43
+ for (const row of h.entries ?? []) {
44
+ this.saved.stream.seen[row.entryId] = String(row.updatedSeq ?? row.seq);
45
+ this.saved.stream.hashes[row.entryId] = createHash('sha256').update(row.body ?? '').digest('hex');
46
+ }
47
+ this.commit();
48
+ }
49
+ if (this.trim()) this.commit();
50
+ this.running = this.loop();
51
+ }
52
+ trim() {
53
+ const state = this.saved.stream;
54
+ const oldSize = Object.keys(state.seen).length;
55
+ const oldHashSize = Object.keys(state.hashes ?? {}).length;
56
+ const ids = Object.keys(state.seen).sort((a, b) => BigInt(state.seen[a]) > BigInt(state.seen[b]) ? -1 : 1).slice(0, TRANSCRIPT_CACHE_LIMIT);
57
+ state.seen = Object.fromEntries(ids.map(id => [id, state.seen[id]]));
58
+ state.hashes = Object.fromEntries(ids.map(id => [id, state.hashes?.[id]]));
59
+ return oldSize !== ids.length || oldHashSize !== ids.length;
60
+ }
61
+ async rows(rows) {
62
+ if (rows.agentId !== this.bot || (rows.sessionId ?? '') !== '') return;
63
+ const state = this.saved.stream;
64
+ const through = BigInt(rows.replayThrough ?? state.afterUpdatedSeq);
65
+ let changed = false;
66
+ try {
67
+ for (const row of [...(rows.entries ?? [])].sort((a, b) => BigInt(a.updatedSeq) < BigInt(b.updatedSeq) ? -1 : 1)) {
68
+ const seq = String(row.updatedSeq ?? row.seq);
69
+ // Eviction must not turn normal history reconciliation into replay.
70
+ if (state.seen[row.entryId] === undefined && !rows.newGeneration && BigInt(seq) <= through) continue;
71
+ if (state.seen[row.entryId] !== undefined && BigInt(state.seen[row.entryId]) >= BigInt(seq)) continue;
72
+ const body = decode(row);
73
+ const hash = createHash('sha256').update(row.body).digest('hex');
74
+ if (state.hashes[row.entryId] !== hash && !(rows.replay && body.timestampMs < state.baselineAt && !state.seen[row.entryId]))
75
+ await this.event({ entry: { row, body } });
76
+ state.hashes[row.entryId] = hash;
77
+ state.seen[row.entryId] = seq;
78
+ if (BigInt(seq) > BigInt(state.afterUpdatedSeq)) state.afterUpdatedSeq = seq;
79
+ changed = true;
80
+ }
81
+ } finally {
82
+ // Commit delivered rows once per batch, including a partial batch whose
83
+ // later entry failed. Keep only the newest updatedSeq IDs and hashes.
84
+ if (changed) { this.trim(); this.commit(); }
85
+ }
86
+ await this.event({ checkpoint: true });
87
+ }
88
+ async reconcile() {
89
+ const history = await this.history();
90
+ await this.rows({ agentId: this.bot, entries: history.entries });
91
+ }
92
+ async loop() {
93
+ const signal = this.controller.signal;
94
+ while (!signal.aborted) {
95
+ try {
96
+ const stream = await this.client.request('WatchGrokBotTranscripts', {
97
+ cursors: [{ agentId: this.bot, sessionId: '', generation: this.saved.stream.generation, afterUpdatedSeq: this.saved.stream.afterUpdatedSeq }],
98
+ includeUnlistedAgents: true, inlineBodyMaxBytes: 262144,
99
+ }, { stream: true, signal });
100
+ for await (const value of stream) {
101
+ if (signal.aborted) break;
102
+ if (value.connected) await this.event({ connected: true });
103
+ const reset = value.cleared ?? value.cursorTooOld;
104
+ if (reset && (!reset.agentId || reset.agentId === this.bot) && (reset.sessionId ?? '') === '') {
105
+ const history = await this.history({ all: true });
106
+ const previous = this.saved.stream;
107
+ const newGeneration = previous.generation !== (history.generation ?? 0);
108
+ const replayThrough = previous.afterUpdatedSeq;
109
+ this.saved.stream.generation = history.generation ?? 0;
110
+ if (newGeneration) this.saved.stream.seen = {};
111
+ this.saved.stream.afterUpdatedSeq = '0';
112
+ // Keep IDs across cursor resets; the new stream replays history.
113
+ await this.rows({ agentId: this.bot, entries: history.entries, replay: true, replayThrough, newGeneration });
114
+ this.saved.stream.afterUpdatedSeq = sequence(history.entries);
115
+ this.trim(); this.commit();
116
+ this.log('原生游标已重置,静默重连');
117
+ break;
118
+ }
119
+ if (value.rows) await this.rows(value.rows);
120
+ if (value.agentState) for (const live of value.agentState.live ?? [])
121
+ if (live.agentId === this.bot && (live.sessionId ?? '') === '')
122
+ await this.event({ live, snapshot: !!value.agentState.snapshot });
123
+ if (value.agentStateChanged?.agentId === this.bot && value.agentStateChanged.families?.some(f => ['subagents', 'automations'].includes(f)))
124
+ await this.event({ routinesChanged: true });
125
+ if (value.turnFailed?.agentId === this.bot && (value.turnFailed.sessionId ?? '') === '') await this.event({ failed: value.turnFailed });
126
+ if (value.heartbeat) await this.event({ heartbeat: value.heartbeat });
127
+ if (value.heartbeat || value.rows || value.agentState) this.health.success();
128
+ }
129
+ if (signal.aborted) break;
130
+ this.log('原生订阅正常结束,静默重连');
131
+ this.health.success();
132
+ } catch (error) {
133
+ if (signal.aborted) break;
134
+ this.health.failure(error);
135
+ // Authentication errors need a new official login, not a retry loop.
136
+ if (authenticationError(error)) break;
137
+ }
138
+ try { await pause(this.clock, this.health.delay, signal); }
139
+ catch { break; }
140
+ }
141
+ }
142
+ close() { this.controller.abort(); }
143
+ }