@coda-console/plugin-check 0.4.8 → 0.5.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.
@@ -0,0 +1,362 @@
1
+ // Plugins installed from a Git repository (docs/plugins.md「从仓库安装」) or from npm (「从 npm 安装」): where one comes from,
2
+ // as Coda keeps and shares it (a repository, a directory in it and the ref it follows, pinned to a commit; or a package, the
3
+ // tag it follows, pinned to a version and the integrity of its tarball), which directories of a commit are plugins, what a
4
+ // package is as a plugin, what a plugin from elsewhere may not be, and the npm packages it brings. Where the built-in
5
+ // plugins come from (「内置插件的来源」) is said the same way. Reading the repository or the registry is
6
+ // components/plugins/plugin-sources.mjs's.
7
+ import { createHash } from 'node:crypto';
8
+ import { normalize } from 'node:path';
9
+ import { requireCondition as check } from '../../domain/errors.mjs';
10
+ import { offerPoint, offeredCommands, offerProblem, withDependencies } from './plugin-extensions.mjs';
11
+
12
+ export const COMMIT = /^[0-9a-f]{40}$/;
13
+ const ID = /^[a-z][a-z0-9-]{0,39}$/;
14
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
15
+ // All of a plugin directory's files in its repository together; the npm packages installed beside them do not count.
16
+ export const SIZE_LIMIT = 10 * 1024 * 1024;
17
+ // The root of the repository and the directories at most this deep under it: `cursor`, `plugins/cursor`.
18
+ const DEPTH = 2;
19
+ // The npm packages a plugin's manifest may name, which Coda installs from its `package-lock.json`.
20
+ const NPM = ['dependencies', 'optionalDependencies'];
21
+
22
+ // The repository as Git is given it. `owner/repo` is GitHub's; an absolute path is this machine's (for trying a plugin
23
+ // out); anything else Git could run a command for (`ext::`, an option) is refused here, and Git is told so as well. An
24
+ // address carries no credentials: it is kept, shared with the space and shown to every client, and Git uses the ones the
25
+ // backend's account has.
26
+ export function repoAddress(input) {
27
+ const value = typeof input === 'string' ? input.trim().replace(/(.)\/+$/, '$1') : '';
28
+ check(value && value.length <= 500 && !/[\s\0]/.test(value) && !value.startsWith('-'), 'invalid_input', '需要仓库地址');
29
+ check(!/^https:\/\/[^/]*@/i.test(value) && !/^ssh:\/\/[^/@]*:[^/@]*@/i.test(value), 'invalid_input', '仓库地址里不能带账号、密码或令牌:Git 用运行后端的账户已有的凭据');
30
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith('.')) return `https://github.com/${value.replace(/\.git$/, '')}.git`;
31
+ const github = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(\.git)?$/i.exec(value);
32
+ if (github) return `https://github.com/${github[1]}.git`;
33
+ if (/^(https|ssh):\/\/[^/\s]+\/./.test(value) || /^([A-Za-z0-9._-]+@)?[A-Za-z0-9.-]+:[^/:\\]/.test(value) || value.startsWith('/')) return value;
34
+ check(false, 'invalid_input', '仓库地址只能是 https://、ssh://、git@主机:路径、owner/repo 或本机的绝对路径');
35
+ }
36
+ // A plugin's ID as a URL names it: its package name (components/plugins/plugin-rules.mjs), also the name of its version directories.
37
+ export function pluginId(id) {
38
+ check(typeof id === 'string' && ID.test(id), 'invalid_input', '插件 ID 只能用小写字母、数字和「-」,以字母开头');
39
+ return id;
40
+ }
41
+ // A branch, a tag or a commit; empty is the repository's default branch.
42
+ export function sourceRef(input = '') {
43
+ const value = typeof input === 'string' ? input.trim() : null;
44
+ check(value !== null && (value === '' || (value.length <= 200 && /^[A-Za-z0-9._/-]+$/.test(value) && !/^[-/]|\/$|\.\.|\/\/|\.lock$/.test(value))),
45
+ 'invalid_input', '版本只能是分支、标签或提交号');
46
+ return value;
47
+ }
48
+ // A directory of the repository: its root (''), or at most DEPTH names under it.
49
+ export function sourcePath(input = '') {
50
+ const parts = typeof input === 'string' ? input.split('/').filter(Boolean) : null;
51
+ check(parts && parts.length <= DEPTH && parts.every(p => SEGMENT.test(p) && p !== '.' && p !== '..'), 'invalid_input', `插件目录须在仓库根下 ${DEPTH} 层以内`);
52
+ return parts.join('/');
53
+ }
54
+ // A source as a client sends it, as Coda keeps it, and as a space shares it.
55
+ export function checkSource(raw) {
56
+ check(raw && typeof raw === 'object' && !Array.isArray(raw), 'invalid_input', '来源须为对象');
57
+ if (raw.kind === 'npm') return npmSource(raw);
58
+ const extra = Object.keys(raw).filter(k => !['kind', 'repo', 'path', 'ref', 'commit', 'previous'].includes(k));
59
+ check(!extra.length, 'invalid_input', `来源里有不支持的键:${extra.join('、')}`);
60
+ check(raw.kind === undefined || raw.kind === 'git', 'invalid_input', '来源只能是 Git 仓库或 npm 包');
61
+ check(typeof raw.commit === 'string' && COMMIT.test(raw.commit), 'invalid_input', '来源须钉在一个提交上(40 位提交号)');
62
+ check(raw.previous == null || (typeof raw.previous === 'string' && COMMIT.test(raw.previous)), 'invalid_input', '上一个版本须为 40 位提交号');
63
+ return { kind: 'git', repo: repoAddress(raw.repo), path: sourcePath(raw.path ?? ''), ref: sourceRef(raw.ref ?? ''), commit: raw.commit,
64
+ ...(raw.previous && raw.previous !== raw.commit ? { previous: raw.previous } : {}) };
65
+ }
66
+ // Which repository an address is, whatever way it is written: GitHub's by owner and name in any case and over any
67
+ // transport (`owner/repo`, `https://`, `git@github.com:`, `ssh://`), any other by its address without a trailing `.git`.
68
+ export function repoIdentity(repo) {
69
+ const github = /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/i.exec(repo);
70
+ return github ? `github.com/${github[1].toLowerCase()}` : repo.replace(/\.git$/, '');
71
+ }
72
+ // Whether two sources name the same package, at whatever version, or the same directory of the same repository, at whatever
73
+ // commit: a built-in plugin and a version of it from where it comes from. A built-in package's copy names both, the
74
+ // directory being the one its package says it is published from (components/plugins/plugin-rules.mjs dependencyPlugins).
75
+ export const sameSource = (a, b) => !!a?.package && a.package === b?.package
76
+ || !!(a?.repo && b?.repo) && repoIdentity(a.repo) === repoIdentity(b.repo) && (a.path ?? '') === (b.path ?? '');
77
+ // Installed again from the same directory, another commit keeps the one it replaces as `previous`, so that going back is
78
+ // installing that one (the two change places); the same commit again (a retry) keeps what it had. From another
79
+ // repository or directory there is nothing to go back to. A package the same, by its versions.
80
+ export function nextSource(prior, source) {
81
+ const { previous, ...next } = source;
82
+ if (next.kind === 'npm') {
83
+ if (prior?.kind !== 'npm' || prior.package !== next.package) return next;
84
+ if (prior.integrity === next.integrity) return prior.previous ? { ...next, previous: prior.previous } : next;
85
+ return { ...next, previous: { version: prior.version, integrity: prior.integrity } };
86
+ }
87
+ if (prior?.kind !== 'git' || prior.repo !== next.repo || prior.path !== next.path) return next;
88
+ if (prior.commit === next.commit) return prior.previous ? { ...next, previous: prior.previous } : next;
89
+ return { ...next, previous: prior.commit };
90
+ }
91
+
92
+ // Whether a plugin was installed from elsewhere than Coda's own copies: a repository or npm.
93
+ export const fromElsewhere = source => source?.kind === 'git' || source?.kind === 'npm';
94
+ // What names the version of a source on this machine, and its version directory: a repository's commit, or for a package
95
+ // the integrity of its tarball, which names that tarball and no other. The one it had before likewise (`previousKey`).
96
+ export const versionKey = source => source.kind === 'npm' ? createHash('sha256').update(source.integrity).digest('hex').slice(0, 40) : source.commit;
97
+ export const previousKey = source => !source.previous ? null : source.kind === 'npm' ? versionKey({ kind: 'npm', integrity: source.previous.integrity }) : source.previous;
98
+
99
+ // ---- npm ----
100
+ // A package as npm names it (`name`, `@scope/name`) and the version of it wanted: one version, a dist-tag, or (empty) the
101
+ // one its `latest` tag names.
102
+ const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
103
+ export const NPM_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
104
+ const NPM_TAG = /^[A-Za-z][A-Za-z0-9._-]{0,99}$/;
105
+ export const INTEGRITY = /^sha512-[A-Za-z0-9+/]{86}==$/;
106
+ const npmName = name => {
107
+ check(typeof name === 'string' && name.length <= 214 && NPM_NAME.test(name), 'invalid_input', 'npm 包名只能是 name 或 @scope/name(小写字母、数字和「-._~」)');
108
+ return name;
109
+ };
110
+ export function npmSpec(input) {
111
+ const value = typeof input === 'string' ? input.trim() : '', at = value.indexOf('@', value.startsWith('@') ? 1 : 0);
112
+ const wanted = at < 0 ? '' : value.slice(at + 1);
113
+ check(wanted === '' || NPM_VERSION.test(wanted) || NPM_TAG.test(wanted), 'invalid_input', '包名后面只能跟一个版本号或标签,如 @1.2.0、@next');
114
+ return { package: npmName(at < 0 ? value : value.slice(0, at)), wanted };
115
+ }
116
+ // The tag a package installed as `wanted` follows for its updates: the one named, or `latest` for a version.
117
+ export const npmTag = wanted => !wanted || NPM_VERSION.test(wanted) ? 'latest' : wanted;
118
+ // Whether version `a` comes before `b`, as semver orders them: by major, minor and patch, a pre-release before its release,
119
+ // pre-releases by their dot-separated parts (numbers below words, numbers by value); build metadata does not count.
120
+ export function versionBefore(a, b) {
121
+ const parse = version => { const [core, pre] = version.split('+')[0].split(/-(.*)/s); return { core: core.split('.').map(Number), pre: pre ? pre.split('.') : [] }; };
122
+ const x = parse(a), y = parse(b);
123
+ for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] < y.core[i];
124
+ if (!x.pre.length || !y.pre.length) return x.pre.length > 0 && !y.pre.length;
125
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
126
+ const p = x.pre[i], q = y.pre[i];
127
+ if (p === undefined || q === undefined) return p === undefined;
128
+ if (p === q) continue;
129
+ const m = /^\d+$/.test(p), n = /^\d+$/.test(q);
130
+ return m && n ? Number(p) < Number(q) : m !== n ? m : p < q;
131
+ }
132
+ return false;
133
+ }
134
+ // The plugin a package is installed as: its ID made of its name (a scoped one's with its scope: `@acme/kit` is `acme-kit`).
135
+ export function npmPluginId(name) {
136
+ const id = name.replace(/^@/, '').replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '').replace(/^(?=[^a-z])/, 'npm-');
137
+ return pluginId(id.slice(0, 40).replace(/-+$/, ''));
138
+ }
139
+ function npmSource(raw) {
140
+ const extra = Object.keys(raw).filter(k => !['kind', 'package', 'tag', 'version', 'integrity', 'previous'].includes(k));
141
+ check(!extra.length, 'invalid_input', `来源里有不支持的键:${extra.join('、')}`);
142
+ const pinned = (at, what) => {
143
+ check(at && typeof at.version === 'string' && NPM_VERSION.test(at.version), 'invalid_input', `${what}须钉在一个版本号上`);
144
+ check(typeof at.integrity === 'string' && INTEGRITY.test(at.integrity), 'invalid_input', `${what}须带包的 sha512 完整性摘要`);
145
+ return { version: at.version, integrity: at.integrity };
146
+ };
147
+ const tag = raw.tag ?? 'latest', at = pinned(raw, 'npm 来源'), previous = raw.previous == null ? null : pinned(raw.previous, '上一个版本');
148
+ check(typeof tag === 'string' && NPM_TAG.test(tag), 'invalid_input', '跟随的标签只能是字母开头的标签名');
149
+ return { kind: 'npm', package: npmName(raw.package), tag, ...at, ...(previous && previous.integrity !== at.integrity ? { previous } : {}) };
150
+ }
151
+ // What a package without a manifest of Coda's is as a plugin (docs/plugins.md「从 npm 安装」), written beside it as the
152
+ // version directory's package.json, the package as npm has it under `package/`: its Agent Skills (directories with a
153
+ // SKILL.md at its root or in its `skills/`, `skills`), and the MCP server it says it is (`mcpName`, which the MCP Registry
154
+ // asks of a server's package), run from its `bin` by the backend's Node, named after the package without its scope (the
155
+ // scope itself for one called only that: `@playwright/mcp` is `playwright`). Why not, for a package that is neither.
156
+ export function npmManifest(id, own, skills) {
157
+ const contributes = {}, [, scope = '', unscoped = ''] = /^(?:@([^/]+)\/)?(.*)$/.exec(String(own.name));
158
+ if (skills.length) contributes.skills = skills.map(skill => `package/${skill}`);
159
+ if (typeof own.mcpName === 'string' && own.mcpName) {
160
+ const bins = typeof own.bin === 'string' ? { [unscoped]: own.bin } : own.bin && typeof own.bin === 'object' && !Array.isArray(own.bin) ? own.bin : {};
161
+ const names = Object.keys(bins), chosen = names.length === 1 ? names[0] : names.find(name => name === unscoped);
162
+ if (!chosen) return { problem: names.length ? `它有 ${names.length} 个 bin(${names.join('、')}),读不出哪个是 MCP 服务器` : '它写了 mcpName,却没有 bin:读不出怎么启动这个 MCP 服务器' };
163
+ const file = typeof bins[chosen] === 'string' ? normalize(bins[chosen]).replace(/^\.\//, '') : '';
164
+ if (!file || file.startsWith('/') || file.split('/').includes('..')) return { problem: `bin ${chosen} 不是包里的文件` };
165
+ const named = scope && ['mcp', 'server', 'mcp-server'].includes(unscoped) ? scope : unscoped;
166
+ const server = (named.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^(?=[^a-z])/, 'mcp-').slice(0, 40).replace(/-+$/, '') || 'mcp').replace(/^coda$/, 'coda-mcp');
167
+ contributes.mcpServers = { [server]: { command: '${CODA_NODE}', args: [`\${CODA_PLUGIN_PATH}/package/${file}`] } };
168
+ }
169
+ if (!Object.keys(contributes).length) return { problem: '这个 npm 包读不成插件:package.json 没有 contributes,没有 Skill 目录,也不是写了 mcpName 的 MCP 服务器' };
170
+ return { manifest: { name: id, displayName: own.name, description: typeof own.description === 'string' ? own.description.slice(0, 500) : '',
171
+ version: String(own.version ?? ''), contributes } };
172
+ }
173
+ // The packages npm installs for a package from npm, by name with the range it asks for: its dependencies are not pinned
174
+ // (docs/plugins.md「从 npm 安装」), unless it brings an `npm-shrinkwrap.json`, whose versions they then are. Among them the
175
+ // peers it needs (`peerDependencies` not marked optional), which npm installs with it: a package may need nothing else.
176
+ export function npmRanges(own, shrinkwrap) {
177
+ const peers = own?.peerDependencies && typeof own.peerDependencies === 'object' && !Array.isArray(own.peerDependencies) ? own.peerDependencies : {};
178
+ const named = { ...own, dependencies: { ...Object.fromEntries(Object.entries(peers).filter(([name]) => own.peerDependenciesMeta?.[name]?.optional !== true)),
179
+ ...own?.dependencies } };
180
+ return npmPackages(named, shrinkwrap).map(each => shrinkwrap ? each : { ...each, range: true });
181
+ }
182
+
183
+ // ---- What a commit holds ----
184
+ // The directories of a commit (its files' paths, `git ls-tree -r --name-only`) that may be plugins: those with a
185
+ // `package.json`, at the root and DEPTH under it; which of them are is said by their manifests (`contributes`).
186
+ export function candidates(names) {
187
+ return [...new Set(names.filter(name => name === 'package.json' || name.endsWith('/package.json')).map(name => name.slice(0, -'package.json'.length).replace(/\/$/, ''))
188
+ .filter(dir => { const parts = dir ? dir.split('/') : []; return parts.length <= DEPTH && !parts.includes('node_modules'); }))].sort();
189
+ }
190
+ // The directories of a commit that hold Agent Skills with nothing to say they are a plugin's (docs/plugins.md「只有 Skills
191
+ // 的目录」): one whose own subdirectories have a SKILL.md, at the root and DEPTH under it, not in a plugin's directory
192
+ // (`plugins`, those whose manifests say what they contribute). Each is a plugin of those Skills.
193
+ export function skillDirectories(names, plugins) {
194
+ const dirs = new Set();
195
+ for (const name of names) {
196
+ const parts = name.split('/');
197
+ if (parts.length < 2 || parts.at(-1) !== 'SKILL.md' || parts.includes('node_modules')) continue;
198
+ const dir = parts.slice(0, -2).join('/');
199
+ if ((dir ? dir.split('/').length : 0) <= DEPTH) dirs.add(dir);
200
+ }
201
+ return [...dirs].filter(dir => !plugins.some(plugin => plugin === '' || dir === plugin || dir.startsWith(`${plugin}/`))).sort();
202
+ }
203
+ // What a directory of Skills says of itself as a plugin, written beside them as it is read and installed: named after the
204
+ // repository (and the directory, below its root; one called `skills` is its parent's), each subdirectory with a SKILL.md
205
+ // one Skill (`skills`, their names).
206
+ export function skillsManifest(repo, path, skills) {
207
+ const repoName = repo.replace(/\/+$/, '').split(/[/:]/).pop().replace(/\.git$/, '');
208
+ const parts = path ? path.split('/') : [];
209
+ if (parts.at(-1) === 'skills') parts.pop();
210
+ const label = [repoName, ...parts.slice(-1)];
211
+ const id = label.join('-').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').replace(/^(?=[^a-z])/, 'skills-').slice(0, 40).replace(/-+$/, '');
212
+ return { name: id, displayName: label.join('/'), description: `${skills.length} 个 Skill`, version: '',
213
+ contributes: { skills: skills.map(name => name) } };
214
+ }
215
+ // Why a directory's files (`git ls-tree -r -l`: mode, type and size of each) cannot be a plugin's, or null. A link may
216
+ // point anywhere on the machine that takes it, and a submodule is another repository.
217
+ export function treeProblem(entries) {
218
+ const link = entries.find(e => e.mode === '120000'), module = entries.find(e => e.mode === '160000');
219
+ if (link) return `目录里有符号链接:${link.name}`;
220
+ if (module) return `目录里有子模块:${module.name}`;
221
+ return entries.reduce((sum, e) => sum + (Number(e.size) || 0), 0) > SIZE_LIMIT ? `整个目录超过 ${SIZE_LIMIT / 1024 / 1024} MiB` : null;
222
+ }
223
+ // The npm packages a manifest names (`dependencies`, `optionalDependencies`), by name: the version range it asks for, and
224
+ // whether a machine may go without it (a package for another platform).
225
+ const npmNamed = manifest => Object.fromEntries(NPM.flatMap(key => manifest?.[key] && typeof manifest[key] === 'object' && !Array.isArray(manifest[key])
226
+ ? Object.entries(manifest[key]).map(([name, range]) => [name, { range, optional: key === 'optionalDependencies' }]) : []));
227
+ // Why a manifest that reads as a plugin's still cannot come from a repository, or null. The npm packages it names are
228
+ // installed from its lock (the `package-lock.json` beside it; `lock` is null without one), which must be there and name
229
+ // the same: installing them asks nothing of anyone.
230
+ export function manifestProblem(manifest, lock) {
231
+ const named = npmNamed(manifest);
232
+ if (!Object.keys(named).length) return null;
233
+ if (lock === null) return '带 npm 依赖却没有 package-lock.json:在插件目录里运行 npm install 生成它';
234
+ const root = lock?.packages?.[''];
235
+ if (!root || typeof root !== 'object') return 'package-lock.json 读不懂:须由 npm 7 以上生成(lockfileVersion 2 或 3)';
236
+ const locked = npmNamed(root), differ = [...new Set([...Object.keys(named), ...Object.keys(locked)])]
237
+ .filter(name => named[name]?.range !== locked[name]?.range || named[name]?.optional !== locked[name]?.optional);
238
+ return differ.length ? `package-lock.json 与 package.json 的依赖不一致(${differ.join('、')}):在插件目录里运行 npm install 更新它` : null;
239
+ }
240
+ // What installing it takes from npm, as the confirmation lists it: each package the manifest names, at the version its lock
241
+ // pins (the range asked for where the lock pins none), and whether a machine may go without it.
242
+ export function npmPackages(manifest, lock) {
243
+ return Object.entries(npmNamed(manifest)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, { range, optional }]) => {
244
+ const version = lock?.packages?.[`node_modules/${name}`]?.version;
245
+ return { name, version: typeof version === 'string' ? version : String(range), ...(optional ? { optional: true } : {}) };
246
+ });
247
+ }
248
+ // An installed built-in plugin (including an update from its own source) reserves its ID. Uninstalling frees the name;
249
+ // an installed replacement from another source keeps it across refreshes and updates.
250
+ export function builtinProblem(id, source, builtin, installed) {
251
+ const copy = builtin.get(id), row = installed.get(id);
252
+ if (!copy || !row || sameSource(copy.source, source)) return null;
253
+ if (fromElsewhere(row.source) && !sameSource(copy.source, row.source)) return null;
254
+ return `与内置插件 ${id} 同名${copy.source?.repo || copy.source?.package ? ',来源不同' : ''},请先卸载内置插件再安装`;
255
+ }
256
+ // What only one plugin may declare, as said to the user: an agent (sessions name it by its ID alone), a concurrency group
257
+ // (any process takes it by its name), Git's credentials for an address, opening a kind of reference (a view's `handles`)
258
+ // and a key of `contributes` a point takes. A command, as a Skill or a variable, is a
259
+ // project's: one two plugins of a project bring is neither's there (components/plugins/plugins.mjs launch).
260
+ export const claims = plugin => [...plugin.agents.map(a => `Agent ${a.id}`), ...plugin.groups.map(g => `组 ${g.name}`),
261
+ ...(plugin.gitCredentials ?? []).map(c => `${c.url} 的 Git 凭据`), ...(plugin.views ?? []).flatMap(v => (v.handles ?? []).map(kind => `打开 ${kind} 引用`)),
262
+ ...Object.values(plugin.extensionPoints ?? {}).filter(p => p.contributes).map(p => `扩展点 contributes.${p.contributes}`)];
263
+ // Why a plugin cannot sit beside those this machine has (`catalog`: built-in and installed, by ID; `builtin`: the plugins
264
+ // Coda ships, by ID), or null: an installed built-in one's ID from another source (builtinProblem), or an agent, a group
265
+ // or Git's credentials for an address another plugin declares. What it needs of the others — the plugins it depends on,
266
+ // the points it plugs into or writes for (plugin-extensions.mjs offerProblem) — is checked once all of them are there.
267
+ export function catalogProblem(plugin, catalog, builtin, installed) {
268
+ const named = builtinProblem(plugin.id, plugin.source, builtin, installed);
269
+ if (named) return named;
270
+ // Clients do not list a plugin's Git credentials in the confirmation yet: only one Coda ships may bring them.
271
+ if (plugin.gitCredentials?.length && plugin.source?.kind !== 'builtin') return '只有 Coda 自带的插件能提供 Git 凭据';
272
+ // Nor the command that lists repositories to choose from.
273
+ if (plugin.repositories && plugin.source?.kind !== 'builtin') return '只有 Coda 自带的插件能提供仓库列表';
274
+ for (const claim of claims(plugin)) {
275
+ const owner = [...catalog.values()].find(p => p.id !== plugin.id && claims(p).includes(claim));
276
+ if (owner) return `${claim} 已由插件 ${owner.displayName} 声明`;
277
+ }
278
+ return null;
279
+ }
280
+ // What installing a plugin from a repository brings along, in the order installing does it (components/plugins/plugin-extensions.mjs
281
+ // withDependencies, as components/plugins/plugins.mjs installFrom): what this version of it needs, directly or through another,
282
+ // and this machine has not installed; each found among this machine's plugins (`builtin`: one Coda ships, not installed),
283
+ // else in another directory of the same commit (`source`, `siblings`: ID → { path, plugin }), else nowhere (`missing`).
284
+ // What each found one brings is confirmed with it.
285
+ export function together(plugin, { catalog, installed, siblings }) {
286
+ const id = plugin.id, known = new Map([...[...siblings].map(([sibling, at]) => [sibling, at.plugin]), ...catalog, [id, plugin]]);
287
+ const after = catalogAfter(plugin, { catalog, siblings });
288
+ const shown = plugin => ({ displayName: plugin.displayName, contributes: contributions(plugin, after) });
289
+ return withDependencies(known, id).filter(each => each !== id && !(installed.has(each) && catalog.has(each)))
290
+ .map(each => catalog.has(each) ? { id: each, from: 'builtin', ...shown(catalog.get(each)) }
291
+ : siblings.has(each) ? { id: each, from: 'source', path: siblings.get(each).path, ...shown(siblings.get(each).plugin) } : { id: each, from: 'missing' });
292
+ }
293
+ // The catalog once a plugin from a source is installed: this machine's, what of the same commit is installed with it
294
+ // (what it needs, directly or through another, as together finds it), and it. Not the other plugins of the commit: what
295
+ // it and those write for points is confirmed as the points installed say.
296
+ export function catalogAfter(plugin, { catalog, siblings }) {
297
+ const known = new Map([...[...siblings].map(([sibling, at]) => [sibling, at.plugin]), ...catalog, [plugin.id, plugin]]);
298
+ const along = withDependencies(known, plugin.id).filter(each => each !== plugin.id && !catalog.has(each) && siblings.has(each));
299
+ return new Map([...catalog, ...along.map(each => [each, siblings.get(each).plugin]), [plugin.id, plugin]]);
300
+ }
301
+ // What installing it brings, as the confirmation lists it: every command it runs, word for word (those it plugs into
302
+ // another plugin's points too, which that plugin runs — those it writes for a point under a key of `contributes`, where
303
+ // the point says, the catalog beside it (`catalog`) declaring it —, those it puts on every session's PATH, those
304
+ // answering clients' requests, and the one that makes each
305
+ // machine ready for it, run as it is installed or updated), the groups it brings,
306
+ // what it calls its workflows and capabilities, how many settings, and what it tells every agent.
307
+ export function contributions(plugin, catalog = new Map()) {
308
+ const offers = Object.entries(plugin.offers ?? {}).flatMap(([key, raw]) => {
309
+ const found = offerPoint(new Map([...catalog, [plugin.id, plugin]]), key);
310
+ return found ? [{ plugin: found.plugin.id, point: found.point, title: found.declared.title, ...offeredCommands(raw, found.declared) }] : [];
311
+ });
312
+ return {
313
+ // The Agent Skills it lays into the projects it applies to, by name; the variables it gives what starts there, each
314
+ // with its value as written, and the directories it puts on their PATH.
315
+ ...(plugin.skills?.length ? { skills: plugin.skills.map(s => s.name),
316
+ skillDescriptions: Object.fromEntries(plugin.skills.filter(s => s.description).map(s => [s.name, s.description])) } : {}),
317
+ ...(plugin.environment?.length ? { environment: plugin.environment.map(({ name, value }) => ({ name, value })) } : {}),
318
+ ...(plugin.path?.length ? { path: plugin.path } : {}),
319
+ agents: plugin.agents.map(a => ({ id: a.id, displayName: a.displayName, command: a.command })),
320
+ commands: plugin.commands.map(c => ({ name: c.name, command: c.command })),
321
+ ...(plugin.endpoints?.length ? { endpoints: plugin.endpoints.map(e => ({ id: e.id, command: e.command, ...(e.checks ? { checks: e.checks } : {}) })) } : {}),
322
+ // The MCP servers its projects' agents start, word for word: what they run and the variables they are given as written.
323
+ ...(plugin.mcpServers?.length ? { mcpServers: plugin.mcpServers.map(({ name, command, args, env }) => ({ name, command, args, env })) } : {}),
324
+ // Its module the plugin host runs (docs/plugins.md「插件宿主」): code of its own in Coda's Node on this machine.
325
+ ...(plugin.main ? { main: plugin.main } : {}),
326
+ // The pages it opens in the clients, by name, with where they stay and their icon there: they run in the client's
327
+ // sandbox, not as commands here.
328
+ ...(plugin.views?.length ? { views: plugin.views.map(v => ({ id: v.id, title: v.title, ...(v.in ? { in: v.in } : {}), ...(v.icon ? { icon: v.icon } : {}), ...(v.handles ? { handles: v.handles } : {}), ...(v.readout ? { readout: v.readout } : {}),
329
+ ...(v.checkout ? { checkout: v.checkout } : {}) })) } : {}),
330
+ groups: plugin.groups.map(g => ({ name: g.name, capacity: g.capacity })),
331
+ extensions: plugin.extensions.map(e => ({ plugin: e.plugin, point: e.point, command: e.command })),
332
+ // What it writes for points under keys of `contributes` (the resources plugin's panels): by point, how many and the
333
+ // commands in them, each with what it is for.
334
+ ...(offers.length ? { offers } : {}),
335
+ ...(plugin.setup ? { setup: { command: plugin.setup.command } } : {}),
336
+ workflows: plugin.workflows, capabilities: plugin.capabilities, settings: Object.keys(plugin.properties).length,
337
+ ...(plugin.instructions ? { instructions: plugin.instructions } : {}),
338
+ // What each machine installs for it from npm, as read from its repository (components/plugins/plugin-sources.mjs inspect).
339
+ ...(plugin.npm?.length ? { npm: plugin.npm } : {}),
340
+ };
341
+ }
342
+ // The ID a manifest says its plugin has, as readPlugin takes it (not checked): its `id`, or its package name.
343
+ export const manifestId = manifest => manifest?.id ?? manifest?.name;
344
+
345
+ // One plugin of a source as its reading lists it, said against what this machine has (`known`: the catalog, the installed
346
+ // rows, the built-in plugins): what it is, the one of its ID installed here, why it cannot be installed, what comes along
347
+ // with it (`siblings`: the other plugins of the same commit, by ID) and what it brings. `manifest`: what is known of it
348
+ // when it does not read as a plugin, its ID `fallback` without a name.
349
+ export function candidate({ path, manifest, plugin, problem, fallback }, { catalog, installed, builtin }, siblings) {
350
+ const id = plugin?.id ?? (typeof manifestId(manifest) === 'string' ? manifestId(manifest) : fallback);
351
+ const row = installed.get(id), along = plugin ? together(plugin, { catalog, installed, siblings }) : [];
352
+ const missing = along.filter(each => each.from === 'missing').map(each => each.id);
353
+ // What this machine has once it is installed, those installed with it among them: the points it may write for.
354
+ const after = plugin ? catalogAfter(plugin, { catalog, siblings }) : catalog;
355
+ return { path, id, displayName: plugin?.displayName ?? (typeof manifest?.displayName === 'string' ? manifest.displayName : id),
356
+ description: plugin?.description ?? (typeof manifest?.description === 'string' ? manifest.description : ''), version: plugin?.version ?? '',
357
+ installed: row ? row.source ?? builtin.get(id)?.source ?? { kind: 'builtin' } : null,
358
+ problem: problem ?? (plugin ? catalogProblem(plugin, catalog, builtin, installed) ?? offerProblem(plugin, after) : null)
359
+ ?? (missing.length ? `要一起装的 ${missing.join('、')} 不在这台机器上${plugin.source?.kind === 'npm' ? '' : ',也不在这个提交里'}` : null),
360
+ together: along.filter(each => each.from !== 'missing'),
361
+ ...(plugin ? { contributes: contributions(plugin, after) } : {}) };
362
+ }
@@ -0,0 +1,35 @@
1
+ // Read-only Skill documents from the same plugin copy the catalog gives agents (docs/plugins.md「Skills」).
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { isAbsolute, join, relative } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { requireCondition as check } from '../../domain/errors.mjs';
6
+
7
+ export function skillDocument(content) {
8
+ const header = /^(?:\uFEFF)?---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(content);
9
+ let description = '';
10
+ if (header) {
11
+ try {
12
+ const fields = parse(header[1], { maxAliasCount: 20, logLevel: 'silent' });
13
+ if (typeof fields?.description === 'string') description = fields.description.trim();
14
+ } catch { /* Invalid metadata must not hide the original document. */ }
15
+ }
16
+ return { content, description, metadata: header?.[1] ?? '', markdown: header ? content.slice(header[0].length) : content };
17
+ }
18
+
19
+ export function readSkillDocument(plugin, name) {
20
+ const skill = plugin?.skills?.find(skill => skill.name === name);
21
+ check(skill, 'not_found', `没有 Skill ${plugin?.id ?? ''}/${name}`);
22
+ try {
23
+ const path = realpathSync(join(skill.path, 'SKILL.md'));
24
+ const within = relative(realpathSync(plugin.dir), path);
25
+ check(within && within !== '..' && !within.startsWith('../') && !isAbsolute(within), 'not_found', 'Skill 文件不在插件目录里');
26
+ const file = statSync(path);
27
+ check(file.isFile() && file.size <= 2 * 1024 * 1024, 'invalid_skill', 'Skill 文件须为不超过 2 MiB 的文本');
28
+ return { plugin: plugin.id, displayName: plugin.displayName, version: plugin.version, source: plugin.source,
29
+ name: skill.name, ...skillDocument(readFileSync(path, 'utf8')) };
30
+ } catch (error) {
31
+ check(!['ENOENT', 'ENOTDIR'].includes(error.code), 'not_found', '找不到 SKILL.md,插件可能已更新或卸载。');
32
+ check(!['EACCES', 'EPERM'].includes(error.code), 'invalid_skill', '无法读取这个 Skill 的文件。');
33
+ throw error;
34
+ }
35
+ }
@@ -0,0 +1,56 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ContractError } from '../../domain/errors.mjs';
3
+ import { agentFailure } from '../../kernel/agent-failures.mjs';
4
+
5
+ // Plugins report facts about a failed request; Coda owns the policy. No provider names or error-text matching here.
6
+ export function serviceFailure(error) {
7
+ const failure = agentFailure(error);
8
+ return failure?.kind === 'service_unavailable' ? failure : null;
9
+ }
10
+ // The former capacity/model-unavailable policy, shared by every agent that reports a service failure.
11
+ export const RETRY_DELAYS = [15_000, 30_000, 60_000];
12
+ export const continuationPrompt = '继续完成当前任务,沿用已有上下文和已完成的工作,从尚未完成的部分接着执行。';
13
+
14
+ export class PromptRecovery {
15
+ constructor(kernel, { clock, prompt, delays = RETRY_DELAYS }) {
16
+ this.kernel = kernel; this.store = kernel.store; this.clock = clock;
17
+ this.prompt = prompt; this.delays = delays; this.retries = new Map();
18
+ }
19
+ pending(run) { return this.retries.get(run)?.waiting === true; }
20
+ schedule(run, error) {
21
+ const failure = serviceFailure(error);
22
+ if (!failure || run.commandInput || run.autonomous || !run.turn || this.kernel.closing || this.kernel.sessions.adopting) return false;
23
+ const retry = this.retries.get(run) ?? { attempt: 0 };
24
+ if (this.store.turn(run.turn.id).stopRequestedAt !== null || retry.attempt === this.delays.length) return false;
25
+ retry.delay = this.delays[retry.attempt++]; retry.waiting = true;
26
+ this.retries.set(run, retry);
27
+ if (failure.input === 'accepted') this.store.markRunning(run.turn.id);
28
+ this.store.expireRequests(run.turn.id);
29
+ return true;
30
+ }
31
+ wake(run) { this.retries.get(run)?.wake?.(); }
32
+ wakeAll() { for (const run of this.retries.keys()) this.wake(run); }
33
+ async finish(run, waiting) {
34
+ try {
35
+ for (;;) {
36
+ try { await waiting; }
37
+ catch (error) { if (error.code !== 'agent_error' || !this.pending(run)) throw error; }
38
+ if (!run.requestId || !this.pending(run)) return;
39
+ const retry = this.retries.get(run), { kernel } = this;
40
+ if (kernel.closing || run.stopped) throw new ContractError('connection_lost', 'Agent 执行连接中断');
41
+ await new Promise(resolve => {
42
+ const cancel = this.clock.at(kernel.now() + retry.delay, () => this.wake(run));
43
+ retry.wake = () => { cancel(); retry.wake = null; resolve(); };
44
+ });
45
+ if (!run.requestId) return; // Stopped while waiting.
46
+ if (kernel.closing || run.stopped) throw new ContractError('connection_lost', 'Agent 执行连接中断');
47
+ retry.waiting = false;
48
+ const input = this.store.input(this.store.operation(run.requestId).inputId), promptId = randomUUID();
49
+ this.store.run('UPDATE operations SET prompt_id = ?, updated_at = ? WHERE request_id = ?', promptId, kernel.now(), run.requestId);
50
+ run.feed.beginPrompt();
51
+ // The persisted original receipt survives a rejected continuation, including after machine recovery.
52
+ waiting = run.prompt(input.state === 'delivered' ? continuationPrompt : this.prompt(run.requestId), promptId);
53
+ }
54
+ } finally { this.wake(run); this.retries.delete(run); }
55
+ }
56
+ }
@@ -0,0 +1,111 @@
1
+ import { requireCondition as check } from '../../domain/errors.mjs';
2
+ import { AGENT_ID, keys, object, text } from '../../domain/values.mjs';
3
+
4
+ // What a session runs with: its model, effort and mode, and the agent's other config options by their ID (`options`: a
5
+ // choice's ID, or on and off for a switch), as the agent's catalog offers them (docs/agent-protocol.md「ACP 部分」).
6
+ // Absence means resolve the adapter default. Actual values belong to Turn.
7
+ export const OPTIONS_LIMIT = 20;
8
+ export function configOptions(value) {
9
+ if (value === undefined) return undefined;
10
+ object(value, '配置项');
11
+ const entries = Object.entries(value);
12
+ check(entries.length <= OPTIONS_LIMIT, 'invalid_input', `配置项至多 ${OPTIONS_LIMIT} 个`);
13
+ for (const [id, option] of entries) {
14
+ check(id.length > 0 && id.length <= 100 && !/[\0\n]/.test(id), 'invalid_input', '配置项的 ID 无效');
15
+ check(typeof option === 'boolean' || (typeof option === 'string' && option.length > 0 && option.length <= 200 && !option.includes('\0')),
16
+ 'invalid_input', `配置项 ${id} 的值须为开关或选项 ID`);
17
+ }
18
+ return value;
19
+ }
20
+ export function settings(value = {}) {
21
+ object(value, '模型设置');
22
+ keys(value, ['model', 'effort', 'mode', 'options']);
23
+ const options = configOptions(value.options);
24
+ return { ...Object.fromEntries(['model', 'effort', 'mode'].filter(key => value[key] !== undefined).map(key => [key, text(value[key], key)])),
25
+ ...(options ? { options } : {}) };
26
+ }
27
+
28
+ // Upload IDs as the client names them are the request's identity. What is on disk is read once, when the
29
+ // message is first accepted: an agent may since have changed or removed the file, and a replay must still match.
30
+ export function attachments(value = []) {
31
+ check(Array.isArray(value) && value.length <= 20 && new Set(value).size === value.length, 'invalid_input', '附件列表无效');
32
+ return value.map(id => text(id, '附件标识', 100));
33
+ }
34
+ // A message without files keeps the request identity it always had.
35
+ export const withAttachments = (value, ids) => ids.length ? { ...value, attachments: ids } : value;
36
+ // Passages the user picked from the conversation, kept apart from their own words. Together they stay
37
+ // within a message's length, which keeps a request under the 1 MiB body limit.
38
+ export function quotes(value = []) {
39
+ check(Array.isArray(value) && value.length <= 20, 'invalid_input', '引用列表无效');
40
+ const passages = value.map(passage => text(passage, '引用', 20_000));
41
+ check(passages.reduce((sum, passage) => sum + passage.length, 0) <= 100_000, 'invalid_input', '引用合计超过 100000 字');
42
+ return passages;
43
+ }
44
+
45
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
46
+ export function firstMessage(value) {
47
+ object(value, '首次发送');
48
+ keys(value, ['requestId', 'projectId', 'title', 'agent', 'checkout', 'settings', 'text', 'attachments', 'quotes', 'handoffSource', 'parentThreadId',
49
+ 'threadId', 'extensionParent']);
50
+ const handoff = value.handoffSource === undefined ? {} : { handoffSource: text(value.handoffSource, '交接来源', 100) };
51
+ const parent = value.parentThreadId === undefined ? {} : { parentThreadId: text(value.parentThreadId, '父会话', 100) };
52
+ check(!parent.parentThreadId || handoff.handoffSource === parent.parentThreadId, 'invalid_input', '子会话必须引用父会话上下文');
53
+ // Which agents may run is the store's to say (its plugin installed); here only that it names one.
54
+ check(typeof value.agent === 'string' && AGENT_ID.test(value.agent), 'invalid_input', 'Agent 不可用');
55
+ // A session an extension starts for a parent session this kernel does not keep (docs/subagent-design.md「扩展作为调用方」):
56
+ // the extension names it, so that a retry finds it and the parent knows it before the answer comes, and says who calls
57
+ // it — `caller`, which every later call of that parent's must name again, `threadId` and `title` of the parent, and
58
+ // `location`, where the parent is, in words — all as the extension knows them. Only an extension may say so
59
+ // (transport/http.mjs); the child works in a worktree of its own, and is nobody's child here.
60
+ const outside = value.extensionParent === undefined ? {} : (() => {
61
+ const p = object(value.extensionParent, '父会话');
62
+ keys(p, ['caller', 'threadId', 'title', 'location']);
63
+ check(UUID.test(value.threadId ?? ''), 'invalid_input', '扩展发起的子会话须带会话标识(UUID)');
64
+ check(value.parentThreadId === undefined && value.handoffSource === undefined && value.checkout?.kind === 'worktree', 'invalid_input',
65
+ '扩展发起的子会话只在新工作树里开始');
66
+ return { threadId: value.threadId, extensionParent: { caller: text(p.caller, '调用方', 200), threadId: text(p.threadId, '父会话', 100),
67
+ title: typeof p.title === 'string' ? p.title.slice(0, 200) : '', ...(typeof p.location === 'string' && p.location ? { location: p.location.slice(0, 200) } : {}) } };
68
+ })();
69
+ check(value.threadId === undefined || value.extensionParent !== undefined, 'invalid_input', '只有扩展发起的子会话可以指定会话标识');
70
+ const checkout = object(value.checkout, '工作位置');
71
+ keys(checkout, ['kind', 'baseRef']);
72
+ check(['original', 'worktree', 'handoff'].includes(checkout.kind), 'invalid_input', '工作位置无效');
73
+ if (checkout.kind !== 'worktree') {
74
+ check(checkout.baseRef === undefined, 'invalid_input', '此工作位置不接受起点分支');
75
+ check(checkout.kind !== 'handoff' || handoff.handoffSource, 'invalid_input', '沿用工作目录需要交接来源');
76
+ } else {
77
+ text(checkout.baseRef, '起点分支', 500);
78
+ check(!checkout.baseRef.startsWith('-') && !/[\r\n]/.test(checkout.baseRef), 'invalid_input', '起点分支无效');
79
+ }
80
+ const message = text(value.text, '消息', 100_000), passages = quotes(value.quotes);
81
+ return withAttachments({
82
+ ...handoff,
83
+ ...parent,
84
+ ...outside,
85
+ requestId: text(value.requestId, '请求标识', 100),
86
+ projectId: text(value.projectId, '项目标识', 100),
87
+ title: text(value.title ?? message.trim().slice(0, 80), '会话标题'),
88
+ agent: value.agent,
89
+ checkout: checkout.kind === 'worktree' ? { kind: 'worktree', baseRef: checkout.baseRef } : { kind: checkout.kind },
90
+ settings: settings(value.settings),
91
+ text: message,
92
+ ...(passages.length ? { quotes: passages } : {}),
93
+ }, attachments(value.attachments));
94
+ }
95
+
96
+ // Agent tool calls. The calling agent never chooses the child's approval mode: the child follows its parent's
97
+ // (see `delegateMode`), so a manually approved session cannot start an unattended one. `turn` is a ticket checked inside the commit transaction and
98
+ // is not part of request equality: a replay may arrive after the turn has ended.
99
+ export function childSpawn(value) {
100
+ object(value, 'spawn');
101
+ keys(value, ['turn', 'requestId', 'agent', 'prompt', 'title', 'model', 'effort', 'checkout', 'project']);
102
+ check([undefined, 'shared', 'worktree'].includes(value.checkout), 'invalid_input', 'checkout 必须是 shared 或 worktree');
103
+ check(typeof value.agent === 'string' && AGENT_ID.test(value.agent), 'invalid_input', 'agent 须为 Agent 的 ID(agents 工具列出的)');
104
+ const prompt = text(value.prompt, 'prompt', 100_000);
105
+ return { turn: text(value.turn, 'turn', 100), requestId: text(value.requestId, 'requestId', 100), agent: value.agent, prompt,
106
+ title: text(value.title ?? prompt.trim().slice(0, 80), 'title'), checkout: value.checkout ?? 'shared',
107
+ ...(value.project === undefined ? {} : { project: text(value.project, 'project', 200) }),
108
+ settings: settings(Object.fromEntries(['model', 'effort'].filter(k => value[k] !== undefined).map(k => [k, value[k]]))) };
109
+ }
110
+
111
+
@@ -0,0 +1,12 @@
1
+ // What a step needs of where it runs, written as labels (`coda:runsOn`, GitHub's runs-on; components/workflows/workflow-executors.mjs).
2
+ import { requireCondition as check } from '../../domain/errors.mjs';
3
+
4
+ const LABEL = /^[a-z0-9][a-z0-9._-]{0,39}$/;
5
+ // Labels as one set: each as written, once, in order — the key a binding is kept under. `code`: what is wrong when they
6
+ // are not labels (a workflow's, or a request's).
7
+ export function executorLabels(value, where, code = 'invalid_input') {
8
+ const labels = (Array.isArray(value) ? value : String(value ?? '').split(',')).map(label => String(label).trim());
9
+ check(labels.length <= 10 && labels.every(label => LABEL.test(label)), code,
10
+ `${where} 写逗号分隔的标签(小写字母、数字与 . _ -,至多 10 个),如 linux,x64`);
11
+ return [...new Set(labels)].sort();
12
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "commit": "b11513d3ab47131e55301e98452261ab072912b5",
3
+ "packages": {
4
+ "@0dep/piso": "5.2.0",
5
+ "@agentclientprotocol/sdk": "1.4.0",
6
+ "yaml": "2.9.1"
7
+ },
8
+ "files": {
9
+ "check/coda/components/plugins/agent-plugins.mjs": "23016dd0f7350a10f71565a2c44e2c1179299a93e8c859529071099f2ce862d1",
10
+ "check/coda/components/plugins/plugin-extensions.mjs": "e527800a109ec9849975357f841b0c05ecfa50c9d9c34e26d828aef3cb7d3cb3",
11
+ "check/coda/components/plugins/plugin-rules.mjs": "ec7e0dc818dadc973480f34c911c148b3ee30df99aa566d0f7202bbe3c9bf844",
12
+ "check/coda/components/plugins/plugin-source-rules.mjs": "d620ee62fb372e31e5389e27f7202308aab74324b6f5552ffef3844afc7d0001",
13
+ "check/coda/components/plugins/skill-document.mjs": "c5b5e815c42cd836572f7b60e22a46af6ac607fd9df0170fa36f955825636ce6",
14
+ "check/coda/components/prompt-recovery/prompt-recovery.mjs": "aaed8b0528179ed95558c56b6d95412223d5aa895889e3c2d4265ceb82167aec",
15
+ "check/coda/components/session-config/commands.mjs": "5ebea8eefedd2a0db01a09ba9241d15ed37c6e37d4aba882504f172cca27e946",
16
+ "check/coda/components/workflows/executor-labels.mjs": "648ad8187d0e6e32a1d75d9966f57df6b89efbef11cd4db72e67bb060159bf01",
17
+ "check/coda/domain/agent-protocol.mjs": "b3bb79a09738b292a60e430057bb3e9c9949e09564010298b498c880e1c07ec6",
18
+ "check/coda/domain/duration.mjs": "15dd64ef9b02342ad521c66e5968a569432a89dc7568d11afd3e6f3fe37e104b",
19
+ "check/coda/domain/errors.mjs": "773c641309a0d19b93c0b3457428d74ecf636454d9fe91cacf3d944b952831dc",
20
+ "check/coda/domain/values.mjs": "01cc72b8d9c09e3fe16c93ec332ca01148bba859b1c672586d9d07444d7816c4",
21
+ "check/coda/kernel/agent-failures.mjs": "69bd8f76b03d275bee571d6c8628ed87295414232607fb4b9202b00369685bdf",
22
+ "check/coda/resources/plugins/resources/panels/command.mjs": "606f28bf87f87dded9df2db892bc7624c770d795ffab88f2409998346920ae42",
23
+ "check/coda/resources/plugins/resources/panels/panels.mjs": "aee040febb37fd6eebaf1a1da5b3299d2b6a9dfc982e649d9a628291af298179",
24
+ "check/coda/resources/plugins/resources/panels/rules.mjs": "c024c5ede7c1516877128aa6d6796fbe5fd0e14642741b0745481bb1c5ca41d3",
25
+ "check/coda/resources/plugins/resources/panels/sign-in.mjs": "1271a24e33b1b882a1fb9bee03b8e240db8b9bee1b5208f0e1c84499cfb1be7e",
26
+ "check/coda/builtin.json": "a26287e18e07f645c7fcbe85a384d8931c8b9142e03b053e7dd9715b8d0cd153",
27
+ "docs/agent-protocol.md": "6e276c583917ae1f238fb9b51d52acb499b3b16a865497081821dbcc183f240c"
28
+ }
29
+ }