@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/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(['spawn', 'send', 'cancel']);
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
- 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
- }
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.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 = [];
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' }, ...[...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 ?? [];
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
- s.input = null;
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
- 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'},本轮已结束;云端任务未必停止。之后收到的原生消息将标为补充消息。`);
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, turn, create = false) {
102
- if (turn !== undefined && (typeof turn !== 'string' || s.turn?.id !== turn)) fail('轮次已结束或不匹配;迟到的消息不再接收');
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
- const { bot, bound } = JSON.parse(readFileSync(this.file(id), 'utf8'));
119
- 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;
120
126
  }
121
127
  catch (error) { if (error.code !== 'ENOENT') throw error; }
122
128
  }
123
- const instructions = systemPrompt?.content.map(b => b.text).join('\n') ?? '';
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.wait(s, 'waiting');
152
- return { ...(load ? {} : { sessionId: id }), configOptions: this.configs(s), modes: { currentModeId: s.mode, availableModes: MODES } };
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(block => block.type !== 'text' || typeof block.text !== 'string')) fail('Grok Bot 目前只接受文本;请在文本里提供可读取的附件路径');
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
- s.input = { turn: turn.id, kind: inputKind(text), text };
159
- // 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);
160
167
  void (async () => {
161
- if (!s.saved.bound || text.trim() === '/bind') {
162
- 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');
163
173
  const code = await this.rpc.request('_coda/remote/code', { sessionId: s.id }, { signal: turn.controller.signal });
164
- if (s.turn !== turn) return;
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
- const bot = this.settings['grok-bot.bots']?.[s.saved.bot];
170
- const keyPath = this.secrets['grok-bot.bots']?.[s.saved.bot]?.key;
171
- if (!bot?.webhook || !keyPath) throw new Error('请在插件设置的 bots 表填写这个 Bot 的 Webhook 与密文 key,并在会话里选择它。');
172
- const key = readFileSync(keyPath, 'utf8').trim();
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 === 'spawn' && (typeof args.project !== 'string' || !args.project.trim()))
237
- fail('spawn 必须指定目标 project;Bot 所在的空目录没有代码,请选择要修复的项目');
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: cross-project spawn and messages to other
244
- // sessions use its cards; follow-ups/cancel of our children use its ownership checks.
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 === 'spawn' || name === 'send';
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/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 });
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' || (p.value !== '' && !Object.hasOwn(this.settings['grok-bot.bots'] ?? {}, p.value))) fail('未知的 Bot 配置');
301
- if (p.value !== s.saved.bot) {
302
- if (s.turn) fail('请先结束本轮再切换 Bot');
303
- if (s.saved.bot) s.saved.bound = false;
304
- s.saved.bot = p.value;
305
- this.save(s);
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
- 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');
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
- // The HTTP response can be lost after this request succeeds. Keep the
320
- // instructions available to every bind (including a different Bot).
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;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
  }