@coda-console/plugin-check 0.0.0 → 0.4.5

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.
@@ -0,0 +1,416 @@
1
+ // The handshake and live levels for one agent: the checker plays Coda (backend/src/adapters/acp.mjs AcpSession), starts the
2
+ // agent's command the way Coda does, says what Coda says, and holds what the agent says to ACP's schema and to
3
+ // docs/agent-protocol.md. Where Coda would quietly drop or refuse something, the check fails and says what Coda does.
4
+ import { randomBytes, randomUUID } from 'node:crypto';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { ACP_PROTOCOL_VERSION, CANCEL_LIMIT, METHOD_NOT_FOUND, OPEN_LIMIT, SIGNED_OUT, STEERING, STEERING_OUTCOMES, TERM_GRACE, RETRY_DELAYS, continuationPrompt,
9
+ agentErrorMessage, serviceFailure, cleared, clientCapabilities, currentModel, pluginEnv, steeringParams, supportsSteering, systemPrompt, takesInstructions } from './coda.mjs';
10
+ import { temporary } from './leftovers.mjs';
11
+ import { AgentProcess, Exited, Timeout } from './process.mjs';
12
+ import { explain, schemas } from './schema.mjs';
13
+ import { Waits } from './waits.mjs';
14
+
15
+ // `exit`: how long an agent may take to go after SIGTERM, or after its input ends. `cancelReply`: how soon a turn whose work
16
+ // was done answers once it has read the cancel. `pause`: a quiet moment between two turns, as between two messages of a
17
+ // person, for what an agent says after a turn to be heard outside it. `term`: how long Coda's session host waits after
18
+ // SIGTERM before SIGKILL. `steer`: how long a steering request may take to be answered; `steerQuiet`: how long an agent that
19
+ // answered one outside a turn is watched for a turn of its own.
20
+ export const LIMITS = { open: OPEN_LIMIT, turn: 180_000, settle: 60_000, cancel: CANCEL_LIMIT, exit: 5_000, cancelDelay: 500, cancelReply: 5_000,
21
+ pause: 500, term: TERM_GRACE, retryDelays: RETRY_DELAYS, steer: 30_000, steerQuiet: 2_000 };
22
+ // A request of the checker's own, sent right behind a `session/cancel`; nothing answers it but an error (Method not found).
23
+ // The agent reads it after the cancel, so its answer is written after the agent has read the cancel.
24
+ export const BARRIER = '_codaCheck/barrier';
25
+ // What an agent says of a session rather than does in a turn: what it says of these after a cancel is no work.
26
+ const BOOKKEEPING = new Set(['available_commands_update', 'current_mode_update', 'config_option_update', 'session_info_update', 'usage_update']);
27
+ // The session instructions the checker gives in Coda's place (Coda's own are backend/src/domain/client-instructions.mjs: how
28
+ // replies show in its clients, the reply language, the plugins' instructions): a word in them that only an agent that took
29
+ // them in before its first turn can say, asked for in that turn.
30
+ export const CHECK_QUESTION = 'What is the Coda check word?';
31
+ const instructionsFor = word => `# Coda client
32
+
33
+ Your replies are shown in Coda, a graphical client on Mac and iPhone, not in a terminal. This session is run by coda-plugin-check, which plays Coda.
34
+
35
+ If the user asks for the Coda check word, reply with exactly ${word} and nothing else.`;
36
+ // A turn with something to say for a while, and a message for it as it goes; and one that comes once a turn has ended.
37
+ const STEER_TURN = '从 1 数到 20,每个数字单独一行。', STEER_TEXT = '数完后再说一句「插话收到」。', STEER_LATE = '这条插话到的时候,这一轮已经结束了。';
38
+ const secs = ms => (ms / 1000).toFixed(1);
39
+ const short = (text, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text).replace(/\s+/g, ' ');
40
+
41
+ // The environment Coda starts the agent with: the session's (here the checker's), and its plugin's (`plugin`: its directory,
42
+ // settings and the plugins it depends on).
43
+ const agentEnv = (plugin, base) => ({ ...cleared(process.env), ...pluginEnv({ ...plugin, stateDir: join(base, 'state') }) });
44
+
45
+ // One process of the agent with the session it opens, and what it says, taken in order.
46
+ class Session {
47
+ constructor({ agent, plugin, base, say, limits, instructions }) {
48
+ this.say = say; this.limits = limits; this.instructions = instructions;
49
+ this.waits = new Waits({ fail: (rule, message) => this.at.fail(rule, message), warn: (rule, message) => this.at.warn(rule, message) });
50
+ this.turn = null; this.autonomous = null; this.autonomousTurns = 0; this.loading = true; this.pendingWaits = [];
51
+ this.kinds = new Map(); this.malformed = 0; this.said = new Set(); this.listeners = new Set(); this.idleWork = [];
52
+ // The session's working copy, and the plugin's state directory, which Coda makes for it.
53
+ this.cwd = join(base, 'work');
54
+ for (const dir of [this.cwd, join(base, 'state')]) mkdirSync(dir, { recursive: true });
55
+ this.proc = new AgentProcess({ command: agent.command, cwd: this.cwd,
56
+ env: agentEnv(plugin, base),
57
+ onMessage: message => { this.receive(message); this.changed(); }, onNoise: (line, why) => this.noise(line, why) });
58
+ this.proc.exited.then(() => this.changed());
59
+ }
60
+ // The level things are said at now: the live level once a turn has been sent.
61
+ get at() { return this.live ? this.say.live : this.say.handshake; }
62
+ once(key, fn) { if (!this.said.has(key)) { this.said.add(key); fn(); } }
63
+ changed() { for (const listener of this.listeners) listener(); }
64
+ // Resolves true once `condition` holds, false past `ms` or when the process has ended without it holding.
65
+ until(condition, ms) {
66
+ if (condition()) return Promise.resolve(true);
67
+ return new Promise(resolve => {
68
+ const done = value => { clearTimeout(timer); this.listeners.delete(listener); resolve(value); };
69
+ const listener = () => { if (condition()) done(true); else if (this.proc.exit) done(false); };
70
+ const timer = setTimeout(() => done(condition()), Math.max(0, ms));
71
+ this.listeners.add(listener);
72
+ });
73
+ }
74
+ noise(line, why) {
75
+ if (why === 'not-json') this.once('noise', () => this.at.warn('wire.noise', `标准输出里有不是 JSON 的行(「${short(line, 60)}」):Coda 丢掉它;日志请写标准错误`));
76
+ else this.at.fail('wire.shape', `标准输出里有不是 JSON 对象的消息(「${short(line, 60)}」):Coda 不收批量消息,整条丢掉`);
77
+ }
78
+
79
+ receive(m) {
80
+ if ('id' in m) return this.request(m);
81
+ if (m.method === 'session/update') return this.update(m.params);
82
+ if (m.method === '_coda/wait') {
83
+ // Coda keeps what is said while a session opens until it knows the session (docs/agent-protocol.md「Agent 活动」规则 7).
84
+ if (this.loading) this.pendingWaits.push(m.params); else this.wait(m.params);
85
+ return;
86
+ }
87
+ if (m.method === '$/cancel_request') return;
88
+ this.once(`notify:${m.method}`, () => this.at.info('notify.unknown', `Agent 发了 Coda 不认识的通知 ${m.method}:Coda 忽略`));
89
+ }
90
+ update(params) {
91
+ const parsed = schemas.zSessionNotification.safeParse(params);
92
+ if (!parsed.success) {
93
+ this.malformed++;
94
+ this.at.fail('update.schema', `session/update 不合 ACP schema(SDK 1.4.0):${explain(parsed.error, params)}`);
95
+ return;
96
+ }
97
+ // Of a session being opened Coda keeps only its commands and title; a replayed history it does not want.
98
+ if (this.loading) return;
99
+ if (!this.waits.owns(params.sessionId)) {
100
+ this.at.fail('update.session', `session/update 说的会话 ${params.sessionId} 不是本会话,也不是 _coda/wait 登记过的子会话:Coda 丢弃`);
101
+ return;
102
+ }
103
+ const kind = params.update.sessionUpdate;
104
+ this.kinds.set(kind, (this.kinds.get(kind) ?? 0) + 1);
105
+ const turn = this.turn ?? this.autonomous;
106
+ if (turn) {
107
+ turn.updates++;
108
+ if (kind === 'agent_message_chunk' && params.update.content?.type === 'text') turn.text += params.update.content.text;
109
+ if (turn.barrier?.answered && !BOOKKEEPING.has(kind)) turn.workAfterCancel.push(kind);
110
+ } else if (!BOOKKEEPING.has(kind)) this.idleWork.push(kind);
111
+ }
112
+ wait(params) {
113
+ this.waits.take(params, { turn: !!this.turn });
114
+ this.track();
115
+ }
116
+ // Coda opens a turn of its own for a wait that goes `handling` outside a turn it sent, and ends it when none is
117
+ // (docs/agent-protocol.md「Agent 活动」规则 3); one that went `handling` in a turn it sent goes on in one as that turn ends.
118
+ track() {
119
+ const handling = this.waits.handling.length;
120
+ if (!this.turn && handling && !this.autonomous) { this.autonomous = { started: Date.now(), updates: 0, text: '' }; this.autonomousTurns++; }
121
+ if (this.autonomous && !handling) { this.lastAutonomous = this.autonomous; this.autonomous = null; }
122
+ }
123
+ request(m) {
124
+ const inTurn = !!(this.turn || this.autonomous);
125
+ if (m.method === 'session/request_permission' || m.method === 'elicitation/create') {
126
+ const permission = m.method === 'session/request_permission', what = permission ? '审批' : '提问';
127
+ const refuse = permission ? { outcome: { outcome: 'cancelled' } } : { action: 'cancel' };
128
+ const parsed = (permission ? schemas.zRequestPermissionRequest : schemas.zCreateElicitationRequest).safeParse(m.params);
129
+ if (!parsed.success) { this.at.fail('request.schema', `${m.method} 不合 ACP schema:${explain(parsed.error, m.params)}`); this.proc.respond(m.id, refuse); return; }
130
+ if (typeof m.params.sessionId === 'string' && !this.waits.owns(m.params.sessionId)) this.at.fail('request.session', `${what}说的会话 ${m.params.sessionId} 不是本会话或登记过的子会话`);
131
+ if (!inTurn) {
132
+ this.at.fail('request.outside-turn', `${what}在一轮之外发来(没有进行中的 session/prompt,也没有 handling 的活动):Coda 拒绝(${permission ? '回 cancelled' : '回 cancel'}),在会话里记一条说明`);
133
+ this.proc.respond(m.id, refuse);
134
+ return;
135
+ }
136
+ if (permission) {
137
+ const reject = m.params.options.find(o => o.kind === 'reject_once') ?? m.params.options.find(o => o.kind === 'reject_always');
138
+ this.proc.respond(m.id, reject ? { outcome: { outcome: 'selected', optionId: reject.optionId } } : refuse);
139
+ this.at.info('request.permission', `审批「${short(m.params.toolCall?.title ?? m.params.toolCall?.toolCallId ?? '')}」:检查一律拒绝(${reject ? `选 ${reject.optionId}` : '没有拒绝的选项,回 cancelled'})`);
140
+ } else {
141
+ this.proc.respond(m.id, { action: 'decline' });
142
+ this.at.info('request.elicitation', `提问「${short(m.params.message ?? '')}」:检查一律回 decline`);
143
+ }
144
+ return;
145
+ }
146
+ // Coda handles nothing else an agent asks: its ACP SDK answers that the method is not found.
147
+ this.proc.respondError(m.id, METHOD_NOT_FOUND, `"Method not found": ${m.method}`);
148
+ this.once(`request:${m.method}`, () => this.at.warn('request.unsupported', `Agent 请求了 ${m.method}:Coda 不提供(没声明 fs、terminal),回 -32601`));
149
+ }
150
+
151
+ // initialize, then session/new or session/load as AcpSession.initialize does. Returns 'ok', 'signed-out' or 'failed'.
152
+ async open(sessionId = null) {
153
+ const say = this.at;
154
+ let reply;
155
+ try { reply = await this.proc.request('initialize', { protocolVersion: ACP_PROTOCOL_VERSION, clientCapabilities: clientCapabilities() }, { timeout: this.limits.open }); }
156
+ catch (e) { this.reportedExit = e instanceof Exited; say.fail('handshake.initialize', `initialize 失败:${e.message}`); return 'failed'; }
157
+ if (reply.error?.code === SIGNED_OUT) { say.skip('handshake.signed-out', `initialize 回了 -32000(未登录):Coda 提示用户去「资源」登录;后面的检查跳过`); return 'signed-out'; }
158
+ if (reply.error) { say.fail('handshake.initialize', `initialize 回了错误 ${reply.error.code}:${reply.error.message}`); return 'failed'; }
159
+ const init = schemas.zInitializeResponse.safeParse(reply.result);
160
+ if (!init.success) { say.fail('handshake.initialize', `initialize 的结果不合 ACP schema:${explain(init.error)}`); return 'failed'; }
161
+ this.init = reply.result;
162
+ if (reply.result.protocolVersion !== ACP_PROTOCOL_VERSION) {
163
+ say.fail('handshake.protocol', `protocolVersion 是 ${reply.result.protocolVersion}:Coda 用的 ACP(SDK 1.4.0)是版本 ${ACP_PROTOCOL_VERSION}(Coda 自己不核对这一项,按它说的协议说话)`);
164
+ return 'failed';
165
+ }
166
+ const caps = reply.result.agentCapabilities ?? {}, waits = !!caps._meta?.coda?.waits && typeof caps._meta.coda.waits === 'object';
167
+ this.canLoad = caps.loadSession === true; this.instructed = takesInstructions(reply.result); this.steering = supportsSteering(reply.result);
168
+ if (!sessionId) {
169
+ say.pass('handshake.initialize', `initialize:protocolVersion ${ACP_PROTOCOL_VERSION},loadSession ${this.canLoad},${waits
170
+ ? '声明了 _meta.coda.waits' : '没声明 _meta.coda.waits(只是声明,登记照收)'}${caps.sessionCapabilities?.additionalDirectories != null ? ',收 additionalDirectories' : ''},${this.steering
171
+ ? '声明了插话(_meta.steering)' : '没声明插话'}`);
172
+ if (this.instructed) say.pass('handshake.system-prompt', '声明了会话指令(sessionCapabilities.systemPrompt):session/new、session/load 带 Coda 的指令(append 模式)');
173
+ else say.fail('handshake.system-prompt', 'initialize 没声明 agentCapabilities.sessionCapabilities.systemPrompt:Coda 照 ACP 的规矩不发会话指令(回复在 Mac、iPhone 上怎样显示、回复语言、插件说明),接入 Coda 的 Agent 必须声明。'
174
+ + '有原生系统提示词入口的放在那里;没有的,在原生会话第一次 session/prompt 时把指令作为单独、明确分隔的一段放在用户输入前面送入,之后与 session/load 恢复的会话不再送');
175
+ }
176
+ if (sessionId && !this.canLoad) { say.fail('handshake.load', '新进程的 initialize 不再声明 loadSession:Coda 恢复不了这个会话'); return 'failed'; }
177
+ const method = sessionId ? 'session/load' : 'session/new';
178
+ // Coda's instructions go with every open of the session, `session/load` too.
179
+ const instructions = this.instructed ? { systemPrompt: systemPrompt(this.instructions) } : {};
180
+ try { reply = await this.proc.request(method, { cwd: this.cwd, mcpServers: [], ...instructions, ...(sessionId ? { sessionId } : {}) }, { timeout: this.limits.open }); }
181
+ catch (e) { this.reportedExit = e instanceof Exited; say.fail(sessionId ? 'handshake.load' : 'handshake.new', `${method} 失败:${e.message}`); return 'failed'; }
182
+ if (reply.error?.code === SIGNED_OUT) { say.skip('handshake.signed-out', `${method} 回了 -32000(未登录):Coda 提示用户去「资源」登录;后面的检查跳过`); return 'signed-out'; }
183
+ if (reply.error) { say.fail(sessionId ? 'handshake.load' : 'handshake.new', `${method} 回了错误 ${reply.error.code}:${reply.error.message}`); return 'failed'; }
184
+ const parsed = (sessionId ? schemas.zLoadSessionResponse : schemas.zNewSessionResponse).safeParse(reply.result);
185
+ if (!parsed.success) { say.fail(sessionId ? 'handshake.load' : 'handshake.new', `${method} 的结果不合 ACP schema:${explain(parsed.error)}`); return 'failed'; }
186
+ this.sessionId = sessionId ?? reply.result.sessionId; this.waits.sessionId = this.sessionId; this.info = reply.result;
187
+ this.loading = false;
188
+ for (const params of this.pendingWaits.splice(0)) this.wait(params);
189
+ const model = currentModel(reply.result);
190
+ if (typeof model !== 'string') {
191
+ say.fail('handshake.model', `${method} 没报当前模型(configOptions 里 category 为 model 的 currentValue,或 models.currentModelId):Coda 每轮开始前核对,没有就不发这一轮(「适配器未报告实际模型」)`);
192
+ return 'failed';
193
+ }
194
+ if (!sessionId) say.pass('handshake.new', `session/new:会话 ${this.sessionId},当前模型 ${model}`);
195
+ return 'ok';
196
+ }
197
+ // One turn of Coda's: `session/prompt` with an ID of Coda's choosing, as AcpSession.prompt sends it. With `cancel`, stopped
198
+ // after the agent first says something, or `cancelDelay` after it was sent; the barrier behind the cancel tells what the
199
+ // agent said and did before it read the cancel from what it said and did after (`workAfterCancel`, `readCancel`). With
200
+ // `steer`, that message is sent into the turn once the agent first says something, as Coda steers a turn under way; its
201
+ // answer is the turn's `steered`. `as`: the rule the turn is said under; one but `live.turn` is judged by the caller, from
202
+ // `last` (the turn).
203
+ async prompt(text, { cancel = false, steer = null, as = 'live.turn', attempt = 0, accepted = false } = {}) {
204
+ const say = this.say.live, id = randomUUID(), rule = cancel ? 'live.cancel' : as;
205
+ const turn = this.last = this.turn = { started: Date.now(), updates: 0, text: '', barrier: null, workAfterCancel: [], readCancel: false, replied: null, steered: null };
206
+ const reply = this.proc.request('session/prompt', { sessionId: this.sessionId, prompt: [{ type: 'text', text }] },
207
+ { id, timeout: this.limits.turn, onReply: () => { turn.readCancel = !!turn.barrier?.answered; turn.replied = Date.now(); this.turn = null; this.track(); this.changed(); } });
208
+ reply.catch(() => { if (this.turn === turn) { this.turn = null; this.track(); this.changed(); } });
209
+ let cancelledAt = null;
210
+ if (cancel) {
211
+ await this.until(() => this.turn !== turn || turn.updates > 0, this.limits.cancelDelay);
212
+ if (this.turn === turn) {
213
+ cancelledAt = Date.now();
214
+ this.proc.notify('session/cancel', { sessionId: this.sessionId });
215
+ // Taken as it is read, in order with the reply to the turn (`onReply`, not a promise's callback, which comes later).
216
+ const barrier = turn.barrier = { answered: null };
217
+ this.proc.request(BARRIER, {}, { timeout: this.limits.cancel, onReply: () => { barrier.answered = Date.now(); } }).catch(() => {});
218
+ }
219
+ }
220
+ if (steer) {
221
+ await this.until(() => this.turn !== turn || turn.updates > 0, this.limits.turn);
222
+ if (this.turn === turn) turn.steered = this.steer(steer);
223
+ }
224
+ let m, timer;
225
+ try {
226
+ m = cancelledAt ? await Promise.race([reply, new Promise((_, reject) => { timer = setTimeout(reject, this.limits.cancel, new Timeout('取消后的 session/prompt', this.limits.cancel)); })])
227
+ : await reply;
228
+ } catch (e) {
229
+ this.reportedExit = e instanceof Exited;
230
+ say.fail(rule, e instanceof Timeout && cancelledAt
231
+ ? `session/cancel 后 ${secs(this.limits.cancel)} 秒这一轮还没结束:Coda 会关掉托管连接与它的进程组,结果未知`
232
+ : `这一轮没有结束:${e.message}`);
233
+ return null;
234
+ } finally { clearTimeout(timer); }
235
+ if (m.error) {
236
+ const failure = serviceFailure(m.error), wait = this.limits.retryDelays[attempt];
237
+ if (!cancel && !text.startsWith('/') && failure && wait !== undefined) {
238
+ say.info('live.service-retry', `服务暂不可用,按 Coda 策略 ${secs(wait)} 秒后第 ${attempt + 1} 次重试`);
239
+ await delay(wait);
240
+ accepted ||= failure.input === 'accepted';
241
+ return this.prompt(accepted ? continuationPrompt : text, { as, attempt: attempt + 1, accepted });
242
+ }
243
+ say.fail(rule, `session/prompt 回了错误 ${m.error.code}:${agentErrorMessage(m.error)}(Coda 把这一轮记为失败)`);
244
+ return null;
245
+ }
246
+ const parsed = schemas.zPromptResponse.safeParse(m.result);
247
+ if (!parsed.success) { say.fail(rule, `session/prompt 的结果不合 ACP schema:${explain(parsed.error)}`); return null; }
248
+ const stop = m.result.stopReason;
249
+ if (rule !== 'live.turn' && !cancel) return stop;
250
+ if (!cancel) {
251
+ say.pass('live.turn', `一轮结束:stopReason ${stop},${secs(Date.now() - turn.started)} 秒,${turn.updates} 条 session/update${turn.text ? `,回复「${short(turn.text)}」` : ''}`);
252
+ if (!turn.text) say.warn('live.reply', '这一轮没有回复文字(agent_message_chunk)');
253
+ } else if (!cancelledAt) say.info('live.cancel', `第二轮在取消前就结束了(stopReason ${stop}),取消没机会生效`);
254
+ else {
255
+ say.pass('live.cancel', `session/cancel 后 ${secs(Date.now() - cancelledAt)} 秒结束,stopReason ${stop}`);
256
+ // A turn that goes on once it has read the cancel — says or does more, or answers only a while later — and then does not
257
+ // say `cancelled` would not stop. One that had ended does not have to: its answer was on its way as the cancel went out,
258
+ // or its work was done and it answers at once (Coda's own Codex entry, waiting on a title, says end_turn then).
259
+ const after = turn.workAfterCancel, late = turn.readCancel && turn.replied - turn.barrier.answered > this.limits.cancelReply;
260
+ if (stop !== 'cancelled' && (after.length || late)) say.fail('live.cancel-reason', `读到 session/cancel 之后这一轮${after.length
261
+ ? `还在接着做(又发了 ${after.length} 条 session/update:${[...new Set(after)].join('、')})` : `又过了 ${secs(turn.replied - turn.barrier.answered)} 秒才答复`},最后回 stopReason ${stop}:ACP 要求停下并回 cancelled;Coda 按回的记,这一轮成了正常结束而不是已停止`);
262
+ else if (stop !== 'cancelled') say.info('live.cancel-reason', `这一轮回了 stopReason ${stop}:${turn.readCancel
263
+ ? '读到 session/cancel 时它已经做完(之后没再做什么,随即答复)' : '答复写出时还没读到 session/cancel(先于紧跟取消发的探测请求的答复)'},取消送到前这一轮已经结束,不算没停`);
264
+ }
265
+ return stop;
266
+ }
267
+ // `_session/steering` as Coda sends it: the agent's answer (`{ outcome, … }`), or `{ error }`.
268
+ async steer(text) {
269
+ try {
270
+ const m = await this.proc.request(STEERING, steeringParams(this.sessionId, text), { timeout: this.limits.steer });
271
+ return m.error ? { error: `回了错误 ${m.error.code}:${m.error.message}` } : m.result ?? {};
272
+ } catch (e) { this.reportedExit = e instanceof Exited; return { error: e.message }; }
273
+ }
274
+ // Waits for every open wait to end, `limit` at most; with `cancel`, stops the first turn of its own the agent takes.
275
+ async settle(limit, { cancel = false } = {}) {
276
+ const say = this.say.live, deadline = Date.now() + limit;
277
+ let stopped = false;
278
+ while (this.waits.open.length && Date.now() < deadline && !this.proc.exit) {
279
+ await this.until(() => !this.waits.open.length || (cancel && !stopped && this.autonomous), deadline - Date.now());
280
+ if (cancel && !stopped && this.autonomous) {
281
+ stopped = true;
282
+ const at = Date.now();
283
+ this.proc.notify('session/cancel', { sessionId: this.sessionId });
284
+ if (await this.until(() => !this.autonomous, this.limits.cancel)) say.pass('live.cancel-autonomous', `自主轮次里发 session/cancel,${secs(Date.now() - at)} 秒后没有项再处于 handling`);
285
+ else say.fail('live.cancel-autonomous', `自主轮次里发 session/cancel 后 ${secs(this.limits.cancel)} 秒还有项在 handling:Coda 会关掉托管连接与它的进程组`);
286
+ }
287
+ }
288
+ return !this.waits.open.length;
289
+ }
290
+ // Stops it as Coda's session host stops an agent (adapters/session-host.mjs): SIGTERM to its process group, its input
291
+ // left open. It should be gone within `exit`; past `term` Coda would already have sent SIGKILL. One that ended before is
292
+ // said to have, unless that was said already. Its process group goes either way.
293
+ async finish(say) {
294
+ if (this.proc.exit) {
295
+ const { code, signal } = this.proc.exit, words = this.proc.lastWords();
296
+ if (!this.reportedExit) say.fail('handshake.exited', `Agent 进程提前退出了(${signal ? `信号 ${signal}` : `退出码 ${code}`})${words ? `:${words}` : ''}`);
297
+ } else {
298
+ const took = await this.proc.terminate(this.limits.exit);
299
+ if (took === null) say.fail('handshake.sigterm', `SIGTERM 后 ${secs(this.limits.exit)} 秒还没退出:Coda 关会话时对 Agent 的进程组发 SIGTERM,${secs(this.limits.term)} 秒后 SIGKILL`);
300
+ else {
301
+ say.pass('handshake.sigterm', `SIGTERM 后 ${secs(took)} 秒退出`);
302
+ if (took > this.limits.term) say.warn('handshake.sigterm-slow', `SIGTERM 后 ${secs(took)} 秒才退出:Coda 只等 ${secs(this.limits.term)} 秒就 SIGKILL,收尾会被打断`);
303
+ }
304
+ }
305
+ this.proc.kill();
306
+ }
307
+ }
308
+
309
+ // The end of its input, in a process of its own once it has answered `initialize`: what an agent sees when whatever runs
310
+ // it is gone without stopping it (Coda's session host killed outright). Coda stops agents with SIGTERM and does not rely on
311
+ // it, so one that stays is only worth a look.
312
+ async function inputEnd({ agent, plugin, base, limits, say }) {
313
+ const proc = new AgentProcess({ command: agent.command, cwd: join(base, 'work'), env: agentEnv(plugin, base) });
314
+ try {
315
+ try { await proc.request('initialize', { protocolVersion: ACP_PROTOCOL_VERSION, clientCapabilities: clientCapabilities() }, { timeout: limits.open }); }
316
+ catch { return; }
317
+ const took = await proc.closeInput(limits.exit);
318
+ if (took !== null) say.pass('handshake.exit-input', `关掉标准输入后 ${secs(took)} 秒退出`);
319
+ else say.warn('handshake.exit-input', `关掉标准输入后 ${secs(limits.exit)} 秒还没退出:Coda 关会话用 SIGTERM,不靠它;但托管它的进程意外没了时,它只看得到输入结束,会一直留着`);
320
+ } finally { proc.kill(); }
321
+ }
322
+
323
+ // The first turn asks for the word in the session instructions (docs/agent-protocol.md「会话指令」): they are to be in the
324
+ // agent's context before it, whether its own system prompt took them or they went in front of the user's input.
325
+ async function instructions(session, word, limits, say) {
326
+ if (!session.instructed) { say.skip('live.instructions', '没声明会话指令:Coda 不发 systemPrompt,指令是否生效查不了'); return true; }
327
+ if (!await session.prompt(CHECK_QUESTION, { as: 'live.instructions' })) return false;
328
+ const text = session.last.text;
329
+ if (text.includes(word)) say.pass('live.instructions', `会话指令在第一轮之前生效:第一轮问指令里的检查词,回复里有 ${word}`);
330
+ else say.fail('live.instructions', `会话指令没在第一轮之前生效:session/new 的 systemPrompt 里写了检查词 ${word},第一轮问它,回复${text ? `「${short(text)}」` : '没有文字'},没有说出它。`
331
+ + '有原生系统提示词入口的放在那里;没有的,在原生会话第一次 session/prompt 时把指令作为单独、明确分隔的一段放在用户输入前面送入');
332
+ await delay(limits.pause);
333
+ return true;
334
+ }
335
+
336
+ // docs/agent-protocol.md「插话」: Coda steers only a turn of its own under way, but the turn may have ended by the time the
337
+ // message is read. One that has is answered promptRequired, and the agent takes no turn of its own for it; one under way
338
+ // takes it in (injected) and still ends with the original `session/prompt`. Anything else Coda records as unknown.
339
+ async function steering(session, limits, say) {
340
+ if (!session.steering) { say.skip('live.steering', '没声明插话(_meta.steering.supported):用户「立即插话」时 Coda 停止这一轮,结束后再把消息作为下一轮发出'); return; }
341
+ const answer = result => result.error ?? (result.outcome ? `outcome ${result.outcome}` : `结果 ${short(JSON.stringify(result))}`);
342
+ if (!await session.until(() => !session.autonomous, limits.settle)) { say.warn('live.steering', `自主轮次 ${secs(limits.settle)} 秒还没结束,插话没查`); return; }
343
+ const before = session.idleWork.length, late = await session.steer(STEER_LATE);
344
+ if (session.proc.exit) return;
345
+ await delay(limits.steerQuiet);
346
+ const work = session.idleWork.slice(before);
347
+ if (late.outcome !== 'promptRequired') say.fail('live.steering', `一轮已经结束时发 ${STEERING},回了 ${answer(late)}:要回 promptRequired(reason noRunningTurn),消息不收、也不自己另开一轮;Coda 把其他答复记为结果未知,不再把消息作为下一轮发出`);
348
+ else if (work.length) say.fail('live.steering', `一轮已经结束时发 ${STEERING},回了 promptRequired,随后却自己说起话来(${work.length} 条 session/update:${[...new Set(work)].join('、')}):promptRequired 的消息没被接收,Agent 不得自己另开一轮,Coda 会把它作为下一轮发出`);
349
+ else say.pass('live.steering', `一轮已经结束时插话:回 promptRequired${late.reason ? `(${late.reason})` : ''},${secs(limits.steerQuiet)} 秒里没有自己另开一轮`);
350
+ await delay(limits.pause);
351
+ const stop = await session.prompt(STEER_TURN, { steer: STEER_TEXT, as: 'live.steering' }), turn = session.last;
352
+ if (!turn.steered) { if (stop) say.info('live.steering', `这一轮在说出第一句之前就结束了(stopReason ${stop}),一轮进行中的插话没机会发`); return; }
353
+ const steered = await turn.steered;
354
+ if (!STEERING_OUTCOMES.includes(steered.outcome)) say.fail('live.steering', `一轮进行中发 ${STEERING},回了 ${answer(steered)}:要么 injected(加进这一轮),要么 promptRequired(这一轮已经结束);Coda 把其他答复记为结果未知`);
355
+ else if (!stop) return;
356
+ else if (steered.outcome === 'injected') say.pass('live.steering', `一轮进行中插话:injected,原来的 session/prompt 照常结束(stopReason ${stop},${turn.updates} 条 session/update${turn.text ? `,回复「${short(turn.text)}」` : ''})`);
357
+ else say.info('live.steering', `一轮进行中插话:回 promptRequired,插话到时这一轮已经结束(stopReason ${stop})`);
358
+ }
359
+
360
+ async function live(session, { prompt, word }, limits, say) {
361
+ session.live = true;
362
+ if (!await instructions(session, word, limits, say)) return;
363
+ if (!await session.prompt(prompt)) return;
364
+ const settled = await session.settle(limits.settle), waits = session.waits.registered;
365
+ if (waits.length) {
366
+ const kinds = Object.entries(Object.groupBy(waits, w => w.kind)).map(([kind, list]) => `${kind} ×${list.length}`).join(',');
367
+ say[settled ? 'pass' : 'warn']('live.waits', `Agent 活动:登记 ${waits.length} 个(${kinds}),自主轮次 ${session.autonomousTurns} 次${settled ? ',都已结束'
368
+ : `;${secs(limits.settle)} 秒后还有 ${session.waits.open.length} 个没结束(定时与长任务可以这样,不算失败)`}`);
369
+ if (session.lastAutonomous?.text) say.info('live.autonomous', `自主轮次的回复「${short(session.lastAutonomous.text)}」`);
370
+ } else say.info('live.waits', '这一轮没有报 Agent 活动(_coda/wait)');
371
+ await steering(session, limits, say);
372
+ // A second turn, stopped; and a turn of the agent's own that it brings about, stopped too. Coda sends nothing while a turn
373
+ // of the agent's own is under way: a message waits for it to end.
374
+ await new Promise(resolve => setTimeout(resolve, limits.pause));
375
+ if (!await session.until(() => !session.autonomous, limits.settle)) say.warn('live.cancel', `自主轮次 ${secs(limits.settle)} 秒还没结束,第二轮(取消)没发`);
376
+ else {
377
+ await session.prompt(prompt, { cancel: true });
378
+ await session.settle(limits.settle, { cancel: true });
379
+ }
380
+ const kinds = [...session.kinds].map(([kind, n]) => `${kind} ×${n}`).join(',');
381
+ if (!session.malformed) say.pass('live.updates', `session/update 都合 ACP schema(SDK 1.4.0):${kinds || '无'}`);
382
+ }
383
+
384
+ // Checks one declared agent of the plugin in `pluginDir`, with the plugin's `settings` and `dependencies` (by ID, their
385
+ // directories) as Coda gives them; `say(level)` gives the report scope for a level.
386
+ export async function checkAgent({ agent, pluginDir, settings = {}, dependencies = {}, live: running = false, prompt = '只回复 ok', limits: given = {}, say }) {
387
+ const limits = { ...LIMITS, ...given }, temp = temporary('coda-plugin-check-'), base = temp.dir;
388
+ const scopes = { handshake: say('handshake'), live: say('live') }, sessions = [], plugin = { pluginDir, settings, dependencies };
389
+ const word = `coda-${randomBytes(3).toString('hex')}`, instructions = instructionsFor(word);
390
+ try {
391
+ const session = new Session({ agent, plugin, base, say: scopes, limits, instructions });
392
+ sessions.push(session);
393
+ const opened = await session.open();
394
+ if (opened === 'ok' && running) await live(session, { prompt, word }, limits, scopes.live);
395
+ else if (running) scopes.live.skip('live.skipped', opened === 'signed-out' ? '未登录,一轮跳过' : '握手没过,一轮跳过');
396
+ else scopes.live.skip('live.skipped', '一轮要 --live(会真的发一条提示词,可能要登录、花额度)');
397
+ await session.finish(scopes.handshake);
398
+ if (opened !== 'failed') await inputEnd({ agent, plugin, base, limits, say: scopes.handshake });
399
+ if (opened !== 'ok' || !running) return;
400
+ if (!session.canLoad) { scopes.live.skip('live.load', '没声明 loadSession:Coda 不会要求恢复,session/load 不查'); return; }
401
+ const again = new Session({ agent, plugin, base, say: scopes, limits, instructions });
402
+ sessions.push(again);
403
+ again.live = true;
404
+ if (await again.open(session.sessionId) === 'ok') {
405
+ scopes.live.pass('live.load', `新进程 session/load 接上了会话 ${session.sessionId}${again.instructed ? '(同样带着会话指令)' : ''}`);
406
+ // The session goes on in the new process: Coda's next message is a turn of it. An agent that answers the load but keeps
407
+ // no session for it ("Session not found") leaves a resumed session that cannot go on.
408
+ const stop = await again.prompt(prompt, { as: 'live.load-turn' });
409
+ if (stop) scopes.live.pass('live.load-turn', `恢复后的一轮结束:stopReason ${stop}`);
410
+ }
411
+ await again.finish(scopes.live);
412
+ } finally {
413
+ for (const session of sessions) session.proc.kill();
414
+ temp.remove();
415
+ }
416
+ }
package/check/coda.mjs ADDED
@@ -0,0 +1,156 @@
1
+ // What the checker copies from Coda, in one place. Coda is the authority: its「读取来源」(PluginSources.readSource) reads a
2
+ // plugin with the same rules, and wins where the two differ. Each rule names where it came from in the Coda repository at
3
+ // CODA_COMMIT; a Coda PR that changes one of them changes this file too (docs/agent-protocol.md here is synced from the same
4
+ // commit, and test/protocol-copy.test.mjs keeps the two together).
5
+ import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
6
+
7
+ // The Coda commit these rules were copied from (an empty current mode is none chosen).
8
+ export const CODA_COMMIT = '52831f0e2a09b16187f254ad2d19e270723bdc3b';
9
+ // The ACP version Coda speaks: the SDK's (backend/package.json pins @agentclientprotocol/sdk 1.4.0, as package.json here).
10
+ export const ACP_PROTOCOL_VERSION = PROTOCOL_VERSION;
11
+
12
+ // ---- ACP (backend/src/adapters/acp.mjs) ----
13
+ // `initialize` as Coda sends it (AcpSession.initialize): no clientInfo, these capabilities.
14
+ export const clientCapabilities = () => ({
15
+ elicitation: { form: {}, url: {} },
16
+ session: { compaction: {}, configOptions: { boolean: {} } },
17
+ _meta: { coda: { waits: {} } },
18
+ });
19
+ // Session instructions (AcpSession.takesInstructions, initialize): ACP's unstable `systemPrompt`, sent only to an agent that
20
+ // declares it, the same on `session/new` and `session/load`, in `append` mode, one text block (docs/agent-protocol.md「会话指令」).
21
+ export const takesInstructions = init => init?.agentCapabilities?.sessionCapabilities?.systemPrompt != null;
22
+ export const systemPrompt = text => ({ mode: 'append', content: [{ type: 'text', text }] });
23
+ // Steering (AcpSession.supportsSteering, steer; docs/agent-protocol.md「插话」): declared beside the capabilities; sent only
24
+ // while a turn of Coda's own is under way. `injected` joined it; `promptRequired` was not taken, the turn having ended;
25
+ // anything else Coda records as unknown.
26
+ export const STEERING = '_session/steering';
27
+ export const supportsSteering = init => init?._meta?.steering?.supported === true;
28
+ export const steeringParams = (sessionId, text) => ({ sessionId, prompt: [{ type: 'text', text }], _meta: { steering: { idleBehavior: 'promptRequired' } } });
29
+ export const STEERING_OUTCOMES = ['injected', 'promptRequired'];
30
+ // Failure categories and presentation (backend/src/domain/agent-failures.mjs).
31
+ export function agentFailure(error) {
32
+ const failure = error?.data?._meta?.coda?.failure;
33
+ if (!record(failure)) return null;
34
+ return failure.kind === 'quota_exhausted' ||
35
+ (failure.kind === 'service_unavailable' && ['accepted', 'not_accepted'].includes(failure.input)) ? failure : null;
36
+ }
37
+ export function agentErrorMessage(error) {
38
+ const message = typeof error?.message === 'string' && error.message ? error.message : 'Agent 返回错误';
39
+ const detail = typeof error?.data?.message === 'string' ? error.data.message.trim() : '';
40
+ const text = detail && !message.includes(detail) ? `${message}\n\n${detail}` : message;
41
+ return agentFailure(error)?.kind === 'quota_exhausted' && !text.startsWith('额度不足') ? `额度不足\n\n${text}` : text;
42
+ }
43
+ // Service retry policy (backend/src/application/prompt-recovery.mjs).
44
+ export function serviceFailure(error) {
45
+ const failure = agentFailure(error);
46
+ return failure?.kind === 'service_unavailable' ? failure : null;
47
+ }
48
+ // The former capacity/model-unavailable policy, shared by every agent that reports a service failure.
49
+ export const RETRY_DELAYS = [15_000, 30_000, 60_000];
50
+ export const continuationPrompt = '继续完成当前任务,沿用已有上下文和已完成的工作,从尚未完成的部分接着执行。';
51
+
52
+ // The agent refuses until it is signed in: ACP's auth_required (signedOut).
53
+ export const SIGNED_OUT = -32000;
54
+ // What Coda answers an agent request it has no handler for (the SDK's): Coda handles only approvals and forms.
55
+ export const METHOD_NOT_FOUND = -32601;
56
+ // What an agent may say of a wait of its own (validWait, verbatim); anything else Coda drops unread.
57
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
58
+ export const WAIT_STATES = new Set(['waiting', 'handling', 'done', 'failed', 'cancelled']);
59
+ const fits = (value, limit) => value === undefined || (typeof value === 'string' && value.length <= limit);
60
+ export const validWait = p => record(p) && typeof p.sessionId === 'string' && p.sessionId.length > 0 && p.sessionId.length <= 200
61
+ && typeof p.waitId === 'string' && p.waitId.length > 0 && p.waitId.length <= 200 && WAIT_STATES.has(p.state)
62
+ && fits(p.kind, 50) && fits(p.title, 500) && fits(p.detail, 4000) && fits(p.childSessionId, 200) && fits(p.model, 200) && fits(p.outputFile, 4096)
63
+ && fits(p.toolCallId, 200)
64
+ && [p.stoppable, p.recurring].every(flag => flag === undefined || typeof flag === 'boolean')
65
+ && (p.at === undefined || (typeof p.at === 'string' && Number.isFinite(Date.parse(p.at))));
66
+ // The model a session runs with, as Coda reads it (catalog().current.model): every turn Coda configures first, and refuses
67
+ // a session whose agent names none (configure: 「适配器未报告实际模型」).
68
+ export function currentModel(session) {
69
+ const model = session?.configOptions?.find?.(x => x?.category === 'model');
70
+ return model?.currentValue ?? session?.models?.currentModelId;
71
+ }
72
+ // docs/agent-protocol.md「Agent 活动」规则 3: a turn the user stops ends within this, or Coda closes the agent's process group.
73
+ export const CANCEL_LIMIT = 30_000;
74
+ // adapters/session-host.mjs `close`: Coda stops an agent with SIGTERM to its process group, and SIGKILL this long after.
75
+ export const TERM_GRACE = 800;
76
+ // adapters/acp.mjs `open` and application/option-cache.mjs: opening a session (initialize, session/new or session/load) may take this.
77
+ export const OPEN_LIMIT = 60_000;
78
+
79
+ // ---- What every process Coda runs for a plugin is given (backend/src/domain/plugins.mjs pluginEnv, cleared) ----
80
+ // Its agents', its panels' and the commands it brings alike: Coda's own Node, the plugin's directory, its state, its settings,
81
+ // what is plugged into its points (none here: the checker installs nothing beside it), where the plugins it depends on are
82
+ // (`dependencies`, by ID), and CODA_ARBITER only for an instance's own backend, which the checker is not. What a process
83
+ // inherits is cleared of them first: it never takes another plugin's for its own.
84
+ export const PLUGIN_VARIABLES = ['CODA_NODE', 'CODA_ARBITER', 'CODA_PLUGIN_PATH', 'CODA_PLUGIN_STATE', 'CODA_PLUGIN_SETTINGS', 'CODA_PLUGIN_EXTENSIONS',
85
+ 'CODA_PLUGIN_DEPENDENCIES'];
86
+ export const pluginEnv = ({ pluginDir, stateDir, settings = {}, dependencies = {} }) => ({ CODA_NODE: process.execPath, CODA_PLUGIN_PATH: pluginDir,
87
+ CODA_PLUGIN_STATE: stateDir, CODA_PLUGIN_SETTINGS: JSON.stringify(settings),
88
+ ...(Object.keys(dependencies).length ? { CODA_PLUGIN_DEPENDENCIES: JSON.stringify(dependencies) } : {}) });
89
+ export const cleared = base => Object.fromEntries(Object.entries(base).filter(([name]) => !PLUGIN_VARIABLES.includes(name)));
90
+ // How Coda starts an agent (domain/agents.mjs launch, adapters/session-host.mjs): `sh -c` in its own process group, standard
91
+ // error unread, the session's environment plus its plugin's.
92
+ // A panel's command (application/panels.mjs env): no session's ID, the backend's `node` first on the PATH, and the
93
+ // concurrency groups in use as it starts (`{ now, groups }`; none here).
94
+ export const THREAD_ENV = 'CODA_THREAD_ID';
95
+ export const groupsState = () => JSON.stringify({ now: Date.now(), groups: [] });
96
+
97
+ // ---- Manifests (backend/src/domain/plugins.mjs, agent-plugins.mjs, panels.mjs, plugin-extensions.mjs) ----
98
+ export const PLUGIN_ID = /^[a-z][a-z0-9-]{0,39}$/;
99
+ export const AGENT_ID = /^[a-z][a-z0-9-]{0,39}$/;
100
+ export const PANEL_ID = /^[a-z][a-z0-9-]{0,63}$/;
101
+ export const POINT = /^[a-z][a-z0-9-]{0,39}$/;
102
+ export const SETTING_KEY = /^[a-z][a-z0-9-]*(\.[a-z][A-Za-z0-9]*)+$/;
103
+ export const CAPABILITY = /^[a-z][a-z0-9-]{0,63}$/;
104
+ // What a plugin may contribute (backend/src/components/plugins/plugin-rules.mjs CONTRIBUTES); anything else is refused.
105
+ export const CONTRIBUTES = ['configuration', 'capabilities', 'instructions', 'panels', 'agents', 'extensionPoints', 'extensions', 'groups', 'events', 'commands', 'gitCredentials',
106
+ 'repositories', 'services', 'setup', 'skills', 'environment', 'path', 'endpoints', 'mcpServers', 'views', 'modelProviders'];
107
+ // What only a plugin Coda ships may contribute, one installed from a repository or npm being refused it
108
+ // (backend/src/components/plugins/plugin-source-rules.mjs catalogProblem): the clients do not show these in the confirmation yet.
109
+ export const BUILTIN_ONLY = { gitCredentials: '只有 Coda 自带的插件能提供 Git 凭据', repositories: '只有 Coda 自带的插件能提供仓库列表' };
110
+ // The bundled services a plugin turns on (`contributes.services`, docs/supervisor.md「独立服务进程」): their IDs.
111
+ export const SERVICE = /^[a-z][a-z0-9-]{0,63}$/;
112
+ // A command a plugin puts on every session's and step's PATH (`contributes.commands`): its name, as a shell finds it, which
113
+ // may not be one of Coda's own; what runs for it, one line.
114
+ export const COMMAND = /^[a-z][a-z0-9-]{0,39}$/;
115
+ export const CODA_COMMANDS = ['coda-run', 'coda-event'];
116
+ // An event a plugin publishes (`contributes.events`, Coda's docs/plugins.md「事件」): `<域>.<名>`, in no domain of Coda's own;
117
+ // whether it starts the workflows subscribed to it (`starts`), and whether it ends its subject (`ends`: a run re-run after it
118
+ // is told it again).
119
+ export const EVENT = /^(?=.{3,100}$)[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
120
+ export const CODA_EVENT_DOMAINS = ['workflow', 'session', 'user'];
121
+ export const EVENT_LIMIT = 50;
122
+ export const COMMAND_LIMIT = 4000;
123
+ // A plugin's setup action (`contributes.setup`): `{ command }`, one line as a panel's command is; it may install software, so
124
+ // the checker reads it and does not run it.
125
+ export const SETUP_KEYS = ['command'];
126
+ export const INSTRUCTIONS_LIMIT = 200;
127
+ export const ICON_LIMIT = 32 * 1024;
128
+ export const AGENT_LIMITS = { displayName: 40, modeTitle: 10, modeUnset: 20, modeNote: 100, modeId: 200, setupHint: 200, command: 4000, configFiles: 16, path: 1000 };
129
+ export const PANEL_LIMITS = { title: 40, command: 4000, confirm: 200, note: 200 };
130
+
131
+ // ---- From a repository (backend/src/domain/plugin-sources.mjs) ----
132
+ export const SIZE_LIMIT = 10 * 1024 * 1024;
133
+ export const SEGMENT = /^[A-Za-z0-9._-]+$/;
134
+ export const DEPTH = 2;
135
+ // A plugin's npm dependencies come with its package-lock.json, naming the same (manifestProblem): Coda installs them with
136
+ // `npm ci` in the plugin's version directory.
137
+ export const NPM = ['dependencies', 'optionalDependencies'];
138
+ export const NPM_LOCK = 'package-lock.json';
139
+ // Those kept in `plugins/<id>` here that Coda ships as npm dependencies, `@coda-console/<id>` (docs/plugins.md「内置插件的来源」):
140
+ // a version of the same package may be installed over Coda's. Other sources may use the same ID once the built-in plugin is
141
+ // uninstalled; only the target backend can check its installed state (builtinProblem).
142
+ export const BUILTIN_HERE = ['claude', 'codex', 'codex-accounts', 'grok', 'kimi', 'opencode', 'review'];
143
+ export const BUILTIN_SCOPE = '@coda-console/';
144
+ // An agent, a concurrency group and a command are one plugin's (catalogProblem, claims). Those the built-in plugins declare, by
145
+ // plugin; and `claude`, the agent ID sessions from before #1069 still store (docs/plugins.md「Agent」).
146
+ export const BUILTIN_AGENTS = { 'claude-sdk': 'claude', codex: 'codex', grok: 'grok', kimi: 'kimi', opencode: 'opencode', claude: 'claude' };
147
+ export const BUILTIN_GROUPS = { host: 'host' };
148
+ export const BUILTIN_COMMANDS = { 'host-sim': 'host' };
149
+
150
+ // ---- Panels (backend/src/domain/panels.mjs, application/panels.mjs) ----
151
+ export const ITEM_TYPES = ['card', 'meter', 'stat', 'markdown', 'button', 'error'];
152
+ export const OUTPUT_LIMIT = 64 * 1024;
153
+ export const ARGS_LIMIT = 4 * 1024;
154
+ export const ITEM_LIMITS = { items: 50, cardItems: 20, label: 80, identity: 200, markdown: 4000, error: 500, confirm: 200 };
155
+ // A reading is stopped past this (docs/plugins.md「面板」).
156
+ export const PANEL_READ_LIMIT = 30_000;
@@ -0,0 +1,24 @@
1
+ // The contract check of one plugin directory: the manifest, then each agent's handshake (and with `live`, a turn) and each
2
+ // panel's reading. Coda's「读取来源」stays the authority; this runs the same rules where a plugin is written.
3
+ import { resolve } from 'node:path';
4
+ import { checkAgent } from './agent.mjs';
5
+ import { checkManifest } from './manifest.mjs';
6
+ import { checkPanel } from './panel.mjs';
7
+ import { Report } from './report.mjs';
8
+
9
+ // `agent`: only this agent of the plugin; `manifestOnly`: nothing is run; `live`: one turn each with `prompt`.
10
+ export async function checkPlugin(dir, { agent: only = null, manifestOnly = false, live = false, prompt, limits } = {}) {
11
+ const pluginDir = resolve(dir), report = new Report();
12
+ const plugin = checkManifest(pluginDir, report.scope('manifest'));
13
+ if (!plugin) return { report, plugin };
14
+ const agents = only ? plugin.agents.filter(a => a.id === only) : plugin.agents;
15
+ if (only && !agents.length) report.scope('manifest').fail('options.agent', `清单里没有能检查的 Agent ${only}`);
16
+ if (manifestOnly) {
17
+ if (agents.length || plugin.panels.length) report.scope('handshake').skip('options.manifest', '只查清单(--manifest):不启动 Agent、不读面板');
18
+ return { report, plugin };
19
+ }
20
+ const { settings, dependencies } = plugin;
21
+ for (const agent of agents) await checkAgent({ agent, pluginDir, settings, dependencies, live, prompt, limits, say: level => report.scope(level, `Agent ${agent.id}`) });
22
+ if (!only) for (const panel of plugin.panels) await checkPanel({ panel, pluginDir, settings, dependencies, say: report.scope('handshake', `面板 ${panel.id}`) });
23
+ return { report, plugin };
24
+ }
@@ -0,0 +1,24 @@
1
+ // What the checker starts and makes goes however it ends: the process groups of agents and panels it started (detached,
2
+ // so a Ctrl-C at the terminal does not reach them) and its temporary directories. bin/check.mjs turns SIGINT and SIGTERM
3
+ // into an exit, which runs this.
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ const groups = new Set(), dirs = new Set();
9
+ process.on('exit', () => {
10
+ for (const pid of groups) try { process.kill(-pid, 'SIGKILL'); } catch {}
11
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
12
+ });
13
+
14
+ // A process group started (named by its leader's pid); the returned function says it is gone.
15
+ export function group(pid) {
16
+ if (pid) groups.add(pid);
17
+ return () => groups.delete(pid);
18
+ }
19
+ // A new temporary directory; `remove()` removes it now.
20
+ export function temporary(prefix) {
21
+ const dir = mkdtempSync(join(tmpdir(), prefix));
22
+ dirs.add(dir);
23
+ return { dir, remove: () => { rmSync(dir, { recursive: true, force: true }); dirs.delete(dir); } };
24
+ }