@coda-console/plugin-check 0.0.0-stage → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ // An agent's process as Coda's session host runs it (backend/src/adapters/session-host.mjs): `sh -c` in its own process
2
+ // group, one JSON-RPC message per line on standard input and output. Coda ignores standard error; the checker keeps its
3
+ // tail to say why a process ended. Request IDs are strings, as Coda's are once its host link names them.
4
+ import { spawn } from 'node:child_process';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { createInterface } from 'node:readline';
7
+ import { group } from './leftovers.mjs';
8
+
9
+ const STDERR_TAIL = 4096;
10
+
11
+ export class AgentProcess {
12
+ // `onMessage(message)`: every request and notification the agent sends, in order. `onNoise(line, why)`: a line Coda would drop.
13
+ constructor({ command, cwd, env, onMessage = () => {}, onNoise = () => {} }) {
14
+ this.generation = randomBytes(3).toString('hex'); this.next = 0; this.pending = new Map(); this.stderr = '';
15
+ this.child = spawn('/bin/sh', ['-c', command], { cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
16
+ // Its process group goes with the checker however that ends.
17
+ this.ended = group(this.child.pid);
18
+ this.child.stdin.on('error', () => {});
19
+ this.child.stderr.on('data', chunk => { this.stderr = (this.stderr + chunk).slice(-STDERR_TAIL); });
20
+ this.exit = null;
21
+ this.exited = new Promise(resolve => {
22
+ this.child.on('error', error => { this.exit ??= { code: null, signal: null, error: error.message }; resolve(this.exit); });
23
+ this.child.on('exit', (code, signal) => { this.exit ??= { code, signal }; resolve(this.exit); });
24
+ });
25
+ // What it wrote before it exited is read first: its output closes after it exits, or soon after when a child of it
26
+ // holds the pipe.
27
+ const drained = new Promise(resolve => this.child.on('close', resolve));
28
+ this.exited.then(() => Promise.race([drained, new Promise(resolve => setTimeout(resolve, 200))])).then(() => {
29
+ for (const { reject } of this.pending.values()) reject(new Exited(this.exit, this.lastWords()));
30
+ this.pending.clear();
31
+ });
32
+ createInterface({ input: this.child.stdout }).on('line', line => {
33
+ if (!line.trim()) return;
34
+ let message;
35
+ try { message = JSON.parse(line); } catch { onNoise(line, 'not-json'); return; }
36
+ if (!message || typeof message !== 'object' || Array.isArray(message)) { onNoise(line, 'not-object'); return; }
37
+ if ('id' in message && !('method' in message)) {
38
+ const waiter = this.pending.get(message.id);
39
+ if (waiter) { this.pending.delete(message.id); waiter.resolve(message); }
40
+ return;
41
+ }
42
+ onMessage(message);
43
+ });
44
+ }
45
+ get pid() { return this.child.pid; }
46
+ write(message) { if (!this.exit && this.child.stdin.writable) this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`); }
47
+ // The agent's whole answer, `{ result }` or `{ error }`; rejected with Timeout or Exited. `onReply` runs as the answer is
48
+ // read, before the next line is: what the agent says after it comes after it.
49
+ request(method, params, { timeout, id = `${this.generation}-${this.next++}`, onReply } = {}) {
50
+ return new Promise((resolve, reject) => {
51
+ let timer;
52
+ this.pending.set(id, { resolve: m => { clearTimeout(timer); onReply?.(m); resolve(m); }, reject: e => { clearTimeout(timer); reject(e); } });
53
+ if (timeout) timer = setTimeout(() => { this.pending.delete(id); reject(new Timeout(method, timeout)); }, timeout);
54
+ if (this.exit) { this.pending.delete(id); clearTimeout(timer); reject(new Exited(this.exit, this.lastWords())); return; }
55
+ this.write({ id, method, params });
56
+ });
57
+ }
58
+ notify(method, params) { this.write({ method, params }); }
59
+ respond(id, result) { this.write({ id, result }); }
60
+ respondError(id, code, message) { this.write({ id, error: { code, message } }); }
61
+ // Resolves to how long it took to exit after `start`, or null past `limit`.
62
+ async exitWithin(limit, start = Date.now()) {
63
+ let timer;
64
+ const done = await Promise.race([this.exited, new Promise(resolve => { timer = setTimeout(resolve, limit, null); })]);
65
+ clearTimeout(timer);
66
+ return done ? Date.now() - start : null;
67
+ }
68
+ // Closes its standard input; how long it took to exit, or null past `limit`.
69
+ closeInput(limit) { this.child.stdin.end(); return this.exitWithin(limit); }
70
+ // What Coda's session host does to stop it (adapters/session-host.mjs): SIGTERM to its process group. How long it took to
71
+ // exit, or null past `limit`.
72
+ terminate(limit) { this.signal('SIGTERM'); return this.exitWithin(limit); }
73
+ signal(name) { try { process.kill(-this.child.pid, name); } catch {} }
74
+ // Whatever is left of its process group goes, as Coda's host makes sure.
75
+ kill() { this.signal('SIGKILL'); this.ended(); }
76
+ lastWords() { return this.stderr.split('\n').map(l => l.trim()).filter(Boolean).at(-1)?.slice(0, 300) ?? ''; }
77
+ }
78
+
79
+ export class Timeout extends Error {
80
+ constructor(method, ms) { super(`${method} ${ms / 1000} 秒没有答复`); this.ms = ms; }
81
+ }
82
+ export class Exited extends Error {
83
+ constructor(exit, words) {
84
+ super(`进程退出了(${exit.error ?? (exit.signal ? `信号 ${exit.signal}` : `退出码 ${exit.code}`)})${words ? `:${words}` : ''}`);
85
+ this.exit = exit;
86
+ }
87
+ }
@@ -0,0 +1,29 @@
1
+ // What a check found, in the order found: each line a level (清单, 握手, 一轮), what it is about (the plugin, an agent, a panel),
2
+ // whether it passed, failed, was skipped or is worth a look (warn, info), a rule ID the tests name, and a line of Chinese.
3
+ export const LEVELS = { manifest: '清单', handshake: '握手', live: '一轮' };
4
+ const MARKS = { pass: '✓', fail: '✗', warn: '!', skip: '-', info: '·' };
5
+
6
+ export class Report {
7
+ constructor() { this.items = []; }
8
+ scope(level, subject = '') {
9
+ const add = status => (rule, message) => { this.items.push({ level, subject, status, rule, message }); };
10
+ return { pass: add('pass'), fail: add('fail'), warn: add('warn'), skip: add('skip'), info: add('info') };
11
+ }
12
+ get failures() { return this.items.filter(item => item.status === 'fail'); }
13
+ get ok() { return this.failures.length === 0; }
14
+ count(status) { return this.items.filter(item => item.status === status).length; }
15
+ }
16
+
17
+ // Grouped by level, then by subject in the order first met; within a group, in the order found.
18
+ export function format(report, { title }) {
19
+ const lines = [title], order = Object.keys(LEVELS), subjects = [...new Set(report.items.map(item => item.subject))];
20
+ const rank = item => order.indexOf(item.level) * subjects.length + subjects.indexOf(item.subject);
21
+ let heading = null;
22
+ for (const item of report.items.toSorted((a, b) => rank(a) - rank(b))) {
23
+ const at = `${LEVELS[item.level]}${item.subject ? ` · ${item.subject}` : ''}`;
24
+ if (at !== heading) { lines.push('', at); heading = at; }
25
+ lines.push(` ${MARKS[item.status]} ${item.message}`);
26
+ }
27
+ lines.push('', `结果:${report.ok ? '通过' : '不通过'}(通过 ${report.count('pass')},失败 ${report.count('fail')},跳过 ${report.count('skip')},提醒 ${report.count('warn')})`);
28
+ return lines.join('\n');
29
+ }
@@ -0,0 +1,18 @@
1
+ // ACP's own parsers, from the SDK version Coda pins (package.json). They are its generated zod schemas, which the SDK does
2
+ // not export by name: read from the file next to its entry point.
3
+ const sdk = import.meta.resolve('@agentclientprotocol/sdk');
4
+ export const schemas = await import(new URL('./schema/zod.gen.js', sdk));
5
+
6
+ // One line on why a value does not parse. A union (a session update is one of many kinds) names the branch it meant: the
7
+ // one whose `sessionUpdate` matched.
8
+ export function explain(error, value) {
9
+ let issue = error.issues[0], path = [];
10
+ while (issue?.code === 'invalid_union' && issue.errors?.length) {
11
+ path = [...path, ...issue.path];
12
+ const matched = issue.errors.filter(branch => !branch.some(i => i.path.at(-1) === 'sessionUpdate'));
13
+ if (!matched.length && path.at(-1) === 'update') return `update.sessionUpdate 不是 ACP 认识的类型:${JSON.stringify(value?.update?.sessionUpdate)}`;
14
+ const best = (matched.length ? matched : issue.errors).reduce((a, b) => b.length < a.length ? b : a);
15
+ issue = best[0];
16
+ }
17
+ return `${[...path, ...(issue?.path ?? [])].join('.') || '(整体)'}:${issue?.message ?? '不合 schema'}`;
18
+ }
@@ -0,0 +1,58 @@
1
+ // `_coda/wait` as Coda takes it: validWait first (check/coda.mjs, copied), then the rules of docs/agent-protocol.md
2
+ // 「Agent 活动」. Where Coda drops a report without a word, the checker fails it: an agent that relies on it is not heard.
3
+ import { validWait, WAIT_STATES } from './coda.mjs';
4
+
5
+ export const TERMINAL = new Set(['done', 'failed', 'cancelled']);
6
+ const LIMITS = { sessionId: 200, waitId: 200, kind: 50, title: 500, detail: 4000, childSessionId: 200, model: 200, outputFile: 4096, toolCallId: 200 };
7
+
8
+ // The first part of validWait a report fails, in words.
9
+ export function whyInvalid(p) {
10
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return 'params 须为对象';
11
+ for (const key of ['sessionId', 'waitId']) if (typeof p[key] !== 'string' || !p[key]) return `缺 ${key}`;
12
+ if (!WAIT_STATES.has(p.state)) return `state 只能是 ${[...WAIT_STATES].join('、')},现为 ${JSON.stringify(p.state)}`;
13
+ for (const [key, max] of Object.entries(LIMITS)) if (p[key] !== undefined && (typeof p[key] !== 'string' || p[key].length > max)) return `${key} 须为不超过 ${max} 字的文本`;
14
+ for (const key of ['stoppable', 'recurring']) if (p[key] !== undefined && typeof p[key] !== 'boolean') return `${key} 须为 true 或 false`;
15
+ if (p.at !== undefined && !(typeof p.at === 'string' && Number.isFinite(Date.parse(p.at)))) return `at 读不出时间:${JSON.stringify(p.at)}`;
16
+ return '不合 validWait';
17
+ }
18
+
19
+ export class Waits {
20
+ constructor(say) {
21
+ this.say = say; this.sessionId = null; this.children = new Set(); this.waits = new Map();
22
+ this.registered = []; this.changes = 0;
23
+ }
24
+ owns(id) { return id === this.sessionId || this.children.has(id); }
25
+ get open() { return [...this.waits.values()].filter(w => !TERMINAL.has(w.state)); }
26
+ get handling() { return this.open.filter(w => w.state === 'handling'); }
27
+ // One report, in order with the session's updates; `turn`: whether a turn of Coda's was under way as it came.
28
+ take(p, { turn }) {
29
+ if (!validWait(p)) { this.say.fail('wait.invalid', `_coda/wait 被 Coda 丢弃:${whyInvalid(p)}(${(JSON.stringify(p) ?? String(p)).slice(0, 160)})`); return; }
30
+ if (!this.owns(p.sessionId)) { this.say.fail('wait.session', `_coda/wait 说的会话 ${p.sessionId} 不是本会话,也不是登记过的子会话:Coda 丢弃`); return; }
31
+ const key = `${p.sessionId}\0${p.waitId}`, known = this.waits.get(key);
32
+ if (known && TERMINAL.has(known.state)) {
33
+ this.say.fail('wait.after-terminal', `活动 ${p.waitId} 已经 ${known.state},之后又报 ${p.state}:终态是最终的,同一个 waitId 再说什么都不算`);
34
+ return;
35
+ }
36
+ this.changes++;
37
+ if (!known) {
38
+ const kind = p.kind ?? 'task';
39
+ if (TERMINAL.has(p.state)) {
40
+ this.say.warn('wait.first-terminal', `活动 ${p.waitId} 第一次报告就是 ${p.state}:Coda 不登记它`);
41
+ this.waits.set(key, { waitId: p.waitId, kind, state: p.state });
42
+ return;
43
+ }
44
+ // Coda takes both as they are: a subagent whose session it is not told shows no transcript of its own, a timer without
45
+ // `at` no time (Coda's own Claude entry leaves it out when it cannot work out the next run).
46
+ if (kind === 'subagent' && !p.childSessionId) this.say.warn('wait.subagent-child', `子 Agent ${p.waitId} 没带 childSessionId:Coda 照收,但认不出它的会话:那个会话说的话与报的活动丢弃,审批与提问拒绝,委派清单里点开没有过程,也不能继续同一个子 Agent`);
47
+ if (kind === 'timer' && p.at === undefined) this.say.warn('wait.timer-at', `定时 ${p.waitId} 没带 at(下次触发的时刻):Coda 照收,只是显示不出下次时间`);
48
+ const wait = { waitId: p.waitId, sessionId: p.sessionId, kind, state: p.state, title: p.title, stoppable: p.stoppable === true, inTurn: turn };
49
+ this.waits.set(key, wait);
50
+ this.registered.push(wait);
51
+ } else {
52
+ if (p.kind !== undefined && p.kind !== known.kind) this.say.warn('wait.kind-changed', `活动 ${p.waitId} 的 kind 从 ${known.kind} 改成 ${p.kind}:Coda 按第一次的算`);
53
+ known.state = p.state;
54
+ if (p.stoppable !== undefined) known.stoppable = p.stoppable;
55
+ }
56
+ if (p.childSessionId) this.children.add(p.childSessionId);
57
+ }
58
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@coda-console/plugin-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.4",
4
+ "description": "Coda 插件的契约检查:扮演 Coda 读清单、启动 Agent 讲 ACP 与 _coda/wait、读面板。",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/coda-console/coda-plugins.git"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
14
+ "bin": {
15
+ "coda-plugin-check": "bin/check.mjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "check"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test 'test/**/*.test.mjs'"
23
+ },
24
+ "dependencies": {
25
+ "@agentclientprotocol/sdk": "1.4.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }