@xingjisoft/neutrino 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/assets/fast-planner.txt +57 -0
- package/assets/fast-replanner.txt +28 -0
- package/dist/agents.d.ts +25 -0
- package/dist/app.d.ts +206 -0
- package/dist/app.js +15 -0
- package/dist/background.d.ts +96 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +40 -3
- package/dist/browser.d.ts +18 -0
- package/dist/clipboard.d.ts +16 -0
- package/dist/colors.d.ts +29 -0
- package/dist/compact.d.ts +52 -0
- package/dist/config.d.ts +221 -0
- package/dist/config.js +22 -7
- package/dist/cwd.d.ts +19 -0
- package/dist/fast/artifacts.d.ts +26 -0
- package/dist/fast/artifacts.js +109 -0
- package/dist/fast/engine.d.ts +78 -0
- package/dist/fast/engine.js +657 -0
- package/dist/fast/evidence.d.ts +21 -0
- package/dist/fast/evidence.js +68 -0
- package/dist/fast/planner.d.ts +10 -0
- package/dist/fast/planner.js +51 -0
- package/dist/fast/pool.d.ts +17 -0
- package/dist/fast/pool.js +78 -0
- package/dist/fast/scheduler.d.ts +54 -0
- package/dist/fast/scheduler.js +383 -0
- package/dist/fast/types.d.ts +100 -0
- package/dist/fast/types.js +21 -0
- package/dist/fast/worker.d.ts +23 -0
- package/dist/fast/worker.js +185 -0
- package/dist/header.d.ts +3 -0
- package/dist/index.d.ts +5 -0
- package/dist/llm/approval.d.ts +204 -0
- package/dist/llm/approval.js +14 -3
- package/dist/llm/client.d.ts +118 -0
- package/dist/llm/client.js +57 -8
- package/dist/llm/codeExecutor.d.ts +14 -0
- package/dist/llm/codemode.d.ts +15 -0
- package/dist/llm/context.d.ts +29 -0
- package/dist/llm/context.js +17 -4
- package/dist/llm/diagnostics.d.ts +91 -0
- package/dist/llm/diagnostics.js +242 -0
- package/dist/llm/plan.d.ts +148 -0
- package/dist/llm/retry.d.ts +80 -0
- package/dist/llm/risk.d.ts +21 -0
- package/dist/llm/risk.js +7 -0
- package/dist/llm/runtime.d.ts +189 -0
- package/dist/llm/runtime.js +178 -24
- package/dist/llm/search.d.ts +2 -0
- package/dist/llm/searchNetwork.d.ts +8 -0
- package/dist/llm/subagent.d.ts +112 -0
- package/dist/llm/subagent.js +25 -12
- package/dist/llm/tools.d.ts +20 -0
- package/dist/llm/tools.js +4 -1
- package/dist/llm/workspace.d.ts +6 -0
- package/dist/llm/workspace.js +271 -0
- package/dist/markdown.d.ts +58 -0
- package/dist/migration.d.ts +8 -0
- package/dist/persistence.d.ts +14 -0
- package/dist/plugins/catalog.d.ts +20 -0
- package/dist/plugins/catalog.js +45 -0
- package/dist/plugins/cli.d.ts +1 -0
- package/dist/plugins/cli.js +106 -0
- package/dist/plugins/host.d.ts +121 -0
- package/dist/plugins/host.js +450 -0
- package/dist/plugins/manager.d.ts +24 -0
- package/dist/plugins/manager.js +288 -0
- package/dist/plugins/package.d.ts +5 -0
- package/dist/plugins/package.js +53 -0
- package/dist/plugins/runner.d.ts +1 -0
- package/dist/plugins/runner.js +227 -0
- package/dist/plugins/session.d.ts +12 -0
- package/dist/plugins/session.js +97 -0
- package/dist/plugins/terminal.d.ts +22 -0
- package/dist/plugins/terminal.js +33 -0
- package/dist/plugins/types.d.ts +178 -0
- package/dist/plugins/types.js +6 -0
- package/dist/plugins/validation.d.ts +14 -0
- package/dist/plugins/validation.js +174 -0
- package/dist/plugins/web-sdk.d.ts +58 -0
- package/dist/plugins/web-sdk.js +1 -0
- package/dist/pro/agents.d.ts +34 -0
- package/dist/pro/agents.js +1 -1
- package/dist/pro/config.d.ts +75 -0
- package/dist/pro/config.js +3 -2
- package/dist/pro/preflight.d.ts +21 -0
- package/dist/sanitize.d.ts +34 -0
- package/dist/screens/agentConversation.d.ts +28 -0
- package/dist/screens/agentHistory.d.ts +25 -0
- package/dist/screens/agentSelect.d.ts +16 -0
- package/dist/screens/chat.d.ts +292 -0
- package/dist/screens/chat.js +179 -65
- package/dist/screens/history.d.ts +23 -0
- package/dist/screens/modelSelect.d.ts +29 -0
- package/dist/screens/plugins.d.ts +29 -0
- package/dist/screens/plugins.js +196 -0
- package/dist/screens/settings.d.ts +42 -0
- package/dist/screens/start.d.ts +43 -0
- package/dist/screens/start.js +25 -17
- package/dist/screens/updatePrompt.d.ts +41 -0
- package/dist/selection.d.ts +75 -0
- package/dist/state.d.ts +26 -0
- package/dist/terminal/buffer.d.ts +47 -0
- package/dist/terminal/input.d.ts +70 -0
- package/dist/terminal/renderer.d.ts +43 -0
- package/dist/terminal/restore.d.ts +41 -0
- package/dist/terminal/screen.d.ts +57 -0
- package/dist/tray.d.ts +14 -0
- package/dist/ui.d.ts +123 -0
- package/dist/update.d.ts +71 -0
- package/dist/updateWatch.d.ts +43 -0
- package/dist/web/approvalQueue.d.ts +8 -0
- package/dist/web/approvalQueue.js +59 -0
- package/dist/web/auth.d.ts +14 -0
- package/dist/web/clientState.d.ts +16 -0
- package/dist/web/folderPicker.d.ts +34 -0
- package/dist/web/folderPicker.js +544 -0
- package/dist/web/hub.d.ts +213 -0
- package/dist/web/hub.js +14 -2
- package/dist/web/localApi.d.ts +11 -0
- package/dist/web/localApi.js +260 -0
- package/dist/web/plugins.d.ts +8 -0
- package/dist/web/plugins.js +64 -0
- package/dist/web/server.d.ts +81 -0
- package/dist/web/server.js +127 -19
- package/dist/web/service.d.ts +7 -0
- package/dist/web/service.js +1 -0
- package/dist/web/session.d.ts +176 -0
- package/dist/web/session.js +170 -21
- package/dist/web/settings.d.ts +65 -0
- package/dist/web/terminalBridge.d.ts +15 -0
- package/dist/web/terminalBridge.js +8 -2
- package/dist/width.d.ts +6 -0
- package/docs/plugins/README.md +126 -0
- package/docs/plugins/agent-sdk.md +92 -0
- package/docs/plugins/architecture.md +64 -0
- package/docs/plugins/compatibility.md +34 -0
- package/docs/plugins/development.md +55 -0
- package/docs/plugins/manifest.md +59 -0
- package/docs/plugins/manifest.schema.json +286 -0
- package/docs/plugins/security.md +53 -0
- package/docs/plugins/skills-prompts.md +69 -0
- package/docs/plugins/ui-sdk.md +109 -0
- package/examples/local-api.py +105 -0
- package/examples/plugins/cards/card.html +6 -0
- package/examples/plugins/cards/neutrino.plugin.json +42 -0
- package/examples/plugins/composer/editor.html +10 -0
- package/examples/plugins/composer/neutrino.plugin.json +10 -0
- package/examples/plugins/function-tools/agent.js +32 -0
- package/examples/plugins/function-tools/neutrino.plugin.json +15 -0
- package/examples/plugins/menus-tabs/neutrino.plugin.json +15 -0
- package/examples/plugins/menus-tabs/panel.html +6 -0
- package/examples/plugins/skills-prompts/neutrino.plugin.json +13 -0
- package/examples/plugins/skills-prompts/prompts/evidence.md +1 -0
- package/examples/plugins/skills-prompts/prompts/reviewer.md +1 -0
- package/examples/plugins/skills-prompts/skills/review/SKILL.md +8 -0
- package/examples/plugins/theme/neutrino.plugin.json +12 -0
- package/package.json +66 -42
- package/web/LOCAL-API.md +65 -0
- package/web/README.md +30 -8
- package/web/app.js +256 -67
- package/web/i18n.js +18 -3
- package/web/index.html +3 -1
- package/web/plugins.css +10 -0
- package/web/plugins.js +230 -0
- package/web/style.css +12 -3
- package/web/turns.js +7 -8
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { assert, defaults, ID, object, readManifest, safeFile, satisfies, validateValue } from "./validation.js";
|
|
6
|
+
import { unpackPlugin } from "./package.js";
|
|
7
|
+
export function pluginHome() { return process.env.NEUTRINO_PLUGIN_HOME || path.join(process.env.NEUTRINO_HOME || path.join(os.homedir(), ".neutrino"), "plugins"); }
|
|
8
|
+
export function safeMode() { return process.env.NEUTRINO_SAFE_MODE === "1"; }
|
|
9
|
+
function profileState() { return { plugins: {}, workspaces: {}, providers: {} }; }
|
|
10
|
+
function readState() {
|
|
11
|
+
const file = path.join(pluginHome(), "state.json");
|
|
12
|
+
if (!fs.existsSync(file))
|
|
13
|
+
return { version: 1, revision: 0, packages: {}, profiles: { standard: profileState(), pro: profileState() } };
|
|
14
|
+
const s = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
assert(s.version === 1 && object(s.packages) && object(s.profiles) && object(s.profiles.standard) && object(s.profiles.pro), "Invalid plugin state; use --safe-mode and restore state.json");
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
function transaction(change, expectedRevision) {
|
|
19
|
+
fs.mkdirSync(pluginHome(), { recursive: true, mode: 0o700 });
|
|
20
|
+
const lock = path.join(pluginHome(), ".lock");
|
|
21
|
+
try {
|
|
22
|
+
fs.mkdirSync(lock);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error("Plugin state is being changed by another process; retry after it completes");
|
|
26
|
+
}
|
|
27
|
+
const temporary = path.join(pluginHome(), `state.${randomUUID()}.tmp`);
|
|
28
|
+
try {
|
|
29
|
+
const state = readState();
|
|
30
|
+
assert(expectedRevision === undefined || state.revision === expectedRevision, "Plugin state changed; refresh before saving");
|
|
31
|
+
const result = change(state);
|
|
32
|
+
// An upgrade may remove a capability's enabled dependency or change its major version.
|
|
33
|
+
for (const profile of Object.values(state.profiles))
|
|
34
|
+
for (const values of [profile.plugins, ...Object.values(profile.workspaces)]) {
|
|
35
|
+
let changed = true;
|
|
36
|
+
while (changed) {
|
|
37
|
+
changed = false;
|
|
38
|
+
const effective = { ...profile.plugins, ...values };
|
|
39
|
+
for (const record of Object.values(state.packages))
|
|
40
|
+
if (effective[record.manifest.id]?.enabled && Object.entries(record.manifest.dependencies ?? {}).some(([id, range]) => !state.packages[id] || !effective[id]?.enabled || !satisfies(state.packages[id].manifest.version, range))) {
|
|
41
|
+
values[record.manifest.id] = { ...effective[record.manifest.id], enabled: false };
|
|
42
|
+
changed = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
state.revision++;
|
|
47
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
48
|
+
fs.renameSync(temporary, path.join(pluginHome(), "state.json"));
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
fs.rmSync(temporary, { force: true });
|
|
53
|
+
fs.rmdirSync(lock);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function workspaceKey(cwd) {
|
|
57
|
+
if (!cwd)
|
|
58
|
+
return undefined;
|
|
59
|
+
const canonical = fs.realpathSync(cwd);
|
|
60
|
+
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
61
|
+
}
|
|
62
|
+
function activation(state, id, profile, cwd) {
|
|
63
|
+
const base = state.profiles[profile].plugins[id] ?? { enabled: false, config: {} };
|
|
64
|
+
const key = workspaceKey(cwd);
|
|
65
|
+
const local = key ? state.profiles[profile].workspaces[key]?.[id] : undefined;
|
|
66
|
+
return local ? { ...base, ...local, config: { ...base.config, ...local.config } } : structuredClone(base);
|
|
67
|
+
}
|
|
68
|
+
function table(state, profile, cwd) {
|
|
69
|
+
const key = workspaceKey(cwd);
|
|
70
|
+
return key ? state.profiles[profile].workspaces[key] ??= {} : state.profiles[profile].plugins;
|
|
71
|
+
}
|
|
72
|
+
/** Re-hash the immutable package so a post-install edit invalidates native trust. */
|
|
73
|
+
export function packageIntegrity(directory) {
|
|
74
|
+
const hash = createHash("sha256");
|
|
75
|
+
let files = 0, bytes = 0;
|
|
76
|
+
const visit = (dir, rel = "") => {
|
|
77
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
78
|
+
if ([".git", "node_modules", ".DS_Store"].includes(item.name))
|
|
79
|
+
continue;
|
|
80
|
+
const name = path.posix.join(rel, item.name), file = path.join(dir, item.name);
|
|
81
|
+
assert(!item.isSymbolicLink(), "Plugin packages cannot contain symlinks");
|
|
82
|
+
if (item.isDirectory()) {
|
|
83
|
+
visit(file, name);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
assert(item.isFile(), "Plugin packages must contain regular files");
|
|
87
|
+
const data = fs.readFileSync(file);
|
|
88
|
+
assert(++files <= 2000 && (bytes += data.length) <= 32 * 1024 * 1024, "Plugin exceeds 2000 files or 32 MiB");
|
|
89
|
+
hash.update(name).update("\0").update(String(data.length)).update("\0").update(data);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
visit(directory);
|
|
93
|
+
return `sha256-${hash.digest("hex")}`;
|
|
94
|
+
}
|
|
95
|
+
export function installPlugin(source, expectedRevision, sourceLabel) {
|
|
96
|
+
if (fs.statSync(source).isFile()) {
|
|
97
|
+
const archive = fs.realpathSync(source), unpacked = unpackPlugin(archive);
|
|
98
|
+
try {
|
|
99
|
+
return installPlugin(unpacked.directory, expectedRevision, archive);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
unpacked.dispose();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
assert(typeof source === "string" && path.isAbsolute(path.resolve(source)), "A local plugin directory is required");
|
|
106
|
+
const directory = fs.realpathSync(path.resolve(source));
|
|
107
|
+
assert(fs.statSync(directory).isDirectory(), "Plugin source must be a directory");
|
|
108
|
+
const manifest = readManifest(directory);
|
|
109
|
+
const integrity = packageIntegrity(directory);
|
|
110
|
+
let target = path.join(pluginHome(), "packages", manifest.id, `${manifest.version}-${integrity.slice(7, 23)}`);
|
|
111
|
+
if (fs.existsSync(target) && packageIntegrity(target) !== integrity)
|
|
112
|
+
target += `-repair-${randomUUID().slice(0, 8)}`;
|
|
113
|
+
// Never copy a package directory into itself (also bounds recursive installs).
|
|
114
|
+
assert(!path.resolve(pluginHome()).startsWith(directory + path.sep) && directory !== path.resolve(pluginHome()), "Plugin source cannot contain its installation home");
|
|
115
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
116
|
+
if (!fs.existsSync(target)) {
|
|
117
|
+
const staging = `${target}.${randomUUID()}.tmp`;
|
|
118
|
+
try {
|
|
119
|
+
fs.cpSync(directory, staging, { recursive: true, filter: file => ![".git", "node_modules", ".DS_Store"].includes(path.basename(file)) });
|
|
120
|
+
assert(packageIntegrity(staging) === integrity, "Plugin source changed during installation; retry");
|
|
121
|
+
readManifest(staging);
|
|
122
|
+
fs.renameSync(staging, target);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
transaction(state => {
|
|
129
|
+
const old = state.packages[manifest.id];
|
|
130
|
+
const previous = old && old.integrity !== integrity ? (({ previous: _, ...rest }) => rest)(old) : old?.previous;
|
|
131
|
+
state.packages[manifest.id] = { manifest, directory: target, integrity, source: sourceLabel ?? directory, installedAt: new Date().toISOString(), ...(previous ? { previous } : {}) };
|
|
132
|
+
const newPermissions = (manifest.permissions ?? []).some(p => !old?.manifest.permissions?.includes(p));
|
|
133
|
+
if (old && old.integrity !== integrity && (newPermissions || manifest.runtime === "native"))
|
|
134
|
+
for (const profile of Object.values(state.profiles)) {
|
|
135
|
+
const entries = [profile.plugins, ...Object.values(profile.workspaces)];
|
|
136
|
+
for (const values of entries)
|
|
137
|
+
if (values[manifest.id])
|
|
138
|
+
values[manifest.id] = { ...values[manifest.id], enabled: false, nativeTrust: undefined };
|
|
139
|
+
}
|
|
140
|
+
}, expectedRevision);
|
|
141
|
+
return listPlugins().plugins.find(p => p.manifest.id === manifest.id);
|
|
142
|
+
}
|
|
143
|
+
export function listPlugins(profile = "standard", cwd) {
|
|
144
|
+
const state = readState();
|
|
145
|
+
const plugins = Object.values(state.packages).map(record => {
|
|
146
|
+
const a = activation(state, record.manifest.id, profile, cwd);
|
|
147
|
+
let error;
|
|
148
|
+
try {
|
|
149
|
+
checkRecord(record, a, profile);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
error = String(e.message);
|
|
153
|
+
}
|
|
154
|
+
return { manifest: record.manifest, integrity: record.integrity, source: record.source, installedAt: record.installedAt,
|
|
155
|
+
enabled: a.enabled, config: { ...defaults(record.manifest.configuration), ...a.config }, error, rollback: Boolean(record.previous) };
|
|
156
|
+
}).sort((a, b) => a.manifest.id.localeCompare(b.manifest.id));
|
|
157
|
+
return { revision: state.revision, safeMode: safeMode(), plugins, providers: state.profiles[profile].providers };
|
|
158
|
+
}
|
|
159
|
+
function checkRecord(record, a, profile) {
|
|
160
|
+
assert(record.manifest.profiles === undefined || record.manifest.profiles.includes(profile), `Plugin does not support ${profile}`);
|
|
161
|
+
assert(packageIntegrity(record.directory) === record.integrity, "Installed files changed; reinstall the plugin");
|
|
162
|
+
if (a.enabled && record.manifest.runtime === "native" && record.manifest.entries?.agent)
|
|
163
|
+
assert(a.nativeTrust === record.integrity, "Native code trust is required for this exact package revision");
|
|
164
|
+
if (record.manifest.configuration)
|
|
165
|
+
validateValue({ ...defaults(record.manifest.configuration), ...a.config }, record.manifest.configuration);
|
|
166
|
+
}
|
|
167
|
+
export function configurePlugin(id, patch, profile = "standard", cwd, revision) {
|
|
168
|
+
assert(ID.test(id), "Invalid plugin id");
|
|
169
|
+
assert(patch.enabled === undefined || typeof patch.enabled === "boolean", "enabled must be boolean");
|
|
170
|
+
assert(patch.config === undefined || object(patch.config), "Plugin config must be an object");
|
|
171
|
+
transaction(state => {
|
|
172
|
+
const record = state.packages[id];
|
|
173
|
+
assert(record, "Unknown plugin");
|
|
174
|
+
const old = activation(state, id, profile, cwd);
|
|
175
|
+
const next = { ...old, ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }), ...(patch.config === undefined ? {} : { config: patch.config }) };
|
|
176
|
+
if (patch.trustNative === true)
|
|
177
|
+
next.nativeTrust = record.integrity;
|
|
178
|
+
if (next.enabled || patch.config !== undefined)
|
|
179
|
+
checkRecord(record, next, profile);
|
|
180
|
+
if (next.enabled) {
|
|
181
|
+
for (const [dependency, range] of Object.entries(record.manifest.dependencies ?? {})) {
|
|
182
|
+
assert(state.packages[dependency] && activation(state, dependency, profile, cwd).enabled && satisfies(state.packages[dependency].manifest.version, range), `Enable compatible dependency ${dependency}@${range} first`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
for (const other of Object.values(state.packages))
|
|
187
|
+
if (activation(state, other.manifest.id, profile, cwd).enabled && other.manifest.dependencies?.[id])
|
|
188
|
+
throw new Error(`${other.manifest.id} depends on this plugin; disable it first`);
|
|
189
|
+
}
|
|
190
|
+
table(state, profile, cwd)[id] = next;
|
|
191
|
+
resolveRecords(state, profile, cwd); // cycles fail before persistence
|
|
192
|
+
}, revision);
|
|
193
|
+
}
|
|
194
|
+
export function removePlugin(id, revision) {
|
|
195
|
+
transaction(state => {
|
|
196
|
+
assert(ID.test(id) && state.packages[id], "Unknown plugin");
|
|
197
|
+
for (const other of Object.values(state.packages))
|
|
198
|
+
assert(other.manifest.id === id || !other.manifest.dependencies?.[id], `${other.manifest.id} depends on this plugin; remove the dependent first`);
|
|
199
|
+
delete state.packages[id];
|
|
200
|
+
for (const p of Object.values(state.profiles)) {
|
|
201
|
+
delete p.plugins[id];
|
|
202
|
+
for (const local of Object.values(p.workspaces))
|
|
203
|
+
delete local[id];
|
|
204
|
+
for (const [slot, provider] of Object.entries(p.providers))
|
|
205
|
+
if (provider.startsWith(`${id}/`))
|
|
206
|
+
delete p.providers[slot];
|
|
207
|
+
}
|
|
208
|
+
// Keep immutable package files for in-flight tasks and rollback/debugging.
|
|
209
|
+
}, revision);
|
|
210
|
+
}
|
|
211
|
+
export function rollbackPlugin(id, revision) {
|
|
212
|
+
transaction(state => { const current = state.packages[id]; assert(current?.previous, "No previous plugin version"); const { previous, ...now } = current; state.packages[id] = { ...previous, previous: now }; }, revision);
|
|
213
|
+
}
|
|
214
|
+
export function selectProvider(slot, provider, profile, revision) {
|
|
215
|
+
assert(["chat.composer", "chat.message.renderer", "chat.card.plan", "chat.card.tool", "persona"].includes(slot), "This slot is not replaceable");
|
|
216
|
+
transaction(state => { assert(typeof provider === "string" && provider.length <= 160, "Invalid provider"); if (provider)
|
|
217
|
+
state.profiles[profile].providers[slot] = provider;
|
|
218
|
+
else
|
|
219
|
+
delete state.profiles[profile].providers[slot]; }, revision);
|
|
220
|
+
}
|
|
221
|
+
function resolveRecords(state, profile, cwd) {
|
|
222
|
+
const active = new Map(Object.entries(state.packages).filter(([id]) => activation(state, id, profile, cwd).enabled));
|
|
223
|
+
const done = new Set(), visiting = new Set(), output = [];
|
|
224
|
+
const visit = (id) => {
|
|
225
|
+
if (done.has(id))
|
|
226
|
+
return;
|
|
227
|
+
assert(!visiting.has(id), `Plugin dependency cycle at ${id}`);
|
|
228
|
+
visiting.add(id);
|
|
229
|
+
const record = active.get(id);
|
|
230
|
+
for (const [dep, range] of Object.entries(record.manifest.dependencies ?? {})) {
|
|
231
|
+
assert(active.has(dep) && satisfies(active.get(dep).manifest.version, range), `Missing dependency ${dep}@${range}`);
|
|
232
|
+
visit(dep);
|
|
233
|
+
}
|
|
234
|
+
for (const [dep, range] of Object.entries(record.manifest.optionalDependencies ?? {}))
|
|
235
|
+
if (active.has(dep) && satisfies(active.get(dep).manifest.version, range))
|
|
236
|
+
visit(dep);
|
|
237
|
+
visiting.delete(id);
|
|
238
|
+
done.add(id);
|
|
239
|
+
output.push({ ...record, activation: activation(state, id, profile, cwd) });
|
|
240
|
+
};
|
|
241
|
+
for (const id of [...active.keys()].sort())
|
|
242
|
+
visit(id);
|
|
243
|
+
return output;
|
|
244
|
+
}
|
|
245
|
+
export function pluginRecords(profile = "standard", cwd) {
|
|
246
|
+
if (safeMode())
|
|
247
|
+
return [];
|
|
248
|
+
return resolveRecords(readState(), profile, cwd).map(record => { try {
|
|
249
|
+
checkRecord(record, record.activation, profile);
|
|
250
|
+
return record;
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
return { ...record, error: e.message };
|
|
254
|
+
} });
|
|
255
|
+
}
|
|
256
|
+
export function providerSelections(profile) { return safeMode() ? {} : readState().profiles[profile].providers; }
|
|
257
|
+
export function pluginAsset(id, file, profile, cwd) {
|
|
258
|
+
const record = pluginRecords(profile, cwd).find(r => r.manifest.id === id && !r.error);
|
|
259
|
+
assert(record, "Plugin is disabled or unavailable");
|
|
260
|
+
// Only assets explicitly used by a UI contribution may reach browsers.
|
|
261
|
+
const allowed = new Set([...(record.manifest.contributes?.ui ?? []).map(u => u.view), record.manifest.entries?.web, ...record.manifest.webAssets ?? []]);
|
|
262
|
+
assert(allowed.has(file), "Asset is not a declared plugin view");
|
|
263
|
+
const resolved = safeFile(record.directory, file);
|
|
264
|
+
assert(fs.statSync(resolved).size <= 1_000_000, "Plugin view exceeds 1 MB");
|
|
265
|
+
return fs.readFileSync(resolved, "utf8");
|
|
266
|
+
}
|
|
267
|
+
export function pluginStorage(id, key, value, write = false) {
|
|
268
|
+
assert(ID.test(id) && /^[a-zA-Z0-9_.-]{1,80}$/.test(key) && key !== "..", "Invalid storage key");
|
|
269
|
+
const dir = path.join(pluginHome(), "data", id);
|
|
270
|
+
const file = path.join(dir, `${key}.json`);
|
|
271
|
+
if (!write)
|
|
272
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
|
|
273
|
+
const body = JSON.stringify(value ?? null);
|
|
274
|
+
assert(body.length <= 256_000, "Plugin storage value exceeds 256 KB");
|
|
275
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
276
|
+
const files = fs.readdirSync(dir).filter(name => name.endsWith(".json"));
|
|
277
|
+
assert(files.length < 128 || fs.existsSync(file), "Plugin storage exceeds 128 keys");
|
|
278
|
+
const otherBytes = files.filter(name => name !== `${key}.json`).reduce((sum, name) => sum + fs.statSync(path.join(dir, name)).size, 0);
|
|
279
|
+
assert(otherBytes + Buffer.byteLength(body) <= 8 * 1024 * 1024, "Plugin storage exceeds 8 MiB");
|
|
280
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
281
|
+
try {
|
|
282
|
+
fs.writeFileSync(temp, body, { mode: 0o600 });
|
|
283
|
+
fs.renameSync(temp, file);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
fs.rmSync(temp, { force: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Dependency-free distributable bundle. No extraction of executable installation scripts. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { assert, object, readManifest } from "./validation.js";
|
|
6
|
+
import { packageIntegrity } from "./manager.js";
|
|
7
|
+
export function packPlugin(directory, output) {
|
|
8
|
+
const base = fs.realpathSync(directory), dest = path.resolve(output);
|
|
9
|
+
assert(dest !== base && !dest.startsWith(base + path.sep), "Bundle output must be outside the source package");
|
|
10
|
+
readManifest(directory);
|
|
11
|
+
const integrity = packageIntegrity(directory);
|
|
12
|
+
const files = [];
|
|
13
|
+
const visit = (dir, relative = "") => { for (const item of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
14
|
+
if (["node_modules", ".git", ".DS_Store"].includes(item.name))
|
|
15
|
+
continue;
|
|
16
|
+
const file = path.join(dir, item.name), name = path.posix.join(relative, item.name);
|
|
17
|
+
if (item.isDirectory())
|
|
18
|
+
visit(file, name);
|
|
19
|
+
else
|
|
20
|
+
files.push({ path: name, data: fs.readFileSync(file).toString("base64") });
|
|
21
|
+
} };
|
|
22
|
+
visit(directory);
|
|
23
|
+
fs.writeFileSync(output, JSON.stringify({ format: "neutrino-plugin", version: 1, integrity, files }), { flag: "wx" });
|
|
24
|
+
return output;
|
|
25
|
+
}
|
|
26
|
+
export function unpackPlugin(file) {
|
|
27
|
+
assert(fs.statSync(file).size <= 48 * 1024 * 1024, "Plugin bundle exceeds 48 MiB");
|
|
28
|
+
const bundle = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
assert(object(bundle) && bundle.format === "neutrino-plugin" && bundle.version === 1 && typeof bundle.integrity === "string" && Array.isArray(bundle.files) && bundle.files.length <= 2000, "Invalid plugin bundle");
|
|
30
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "neutrino-plugin-unpack-"));
|
|
31
|
+
try {
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (const item of bundle.files) {
|
|
35
|
+
assert(object(item) && typeof item.path === "string" && typeof item.data === "string", "Invalid bundle file");
|
|
36
|
+
const parts = item.path.split("/");
|
|
37
|
+
assert(parts.every(p => p && p !== "." && p !== ".." && !/[\\:\0]/.test(p)) && !seen.has(item.path), "Unsafe or duplicate bundle path");
|
|
38
|
+
seen.add(item.path);
|
|
39
|
+
const data = Buffer.from(item.data, "base64");
|
|
40
|
+
assert((bytes += data.length) <= 32 * 1024 * 1024, "Plugin data exceeds 32 MiB");
|
|
41
|
+
const dest = path.join(directory, ...parts);
|
|
42
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
43
|
+
fs.writeFileSync(dest, data, { flag: "wx", mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
readManifest(directory);
|
|
46
|
+
assert(packageIntegrity(directory) === bundle.integrity, "Plugin bundle integrity mismatch");
|
|
47
|
+
return { directory, dispose: () => fs.rmSync(directory, { recursive: true, force: true }) };
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** Private disposable process. Native code never runs on the terminal/server event loop. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
/** This function is also serialized into QuickJS. It must have no module dependencies. */
|
|
6
|
+
function bootstrap(bridge, initial) {
|
|
7
|
+
const handlers = new Map();
|
|
8
|
+
const registrations = [];
|
|
9
|
+
const effects = [];
|
|
10
|
+
let counter = 0, sealed = false;
|
|
11
|
+
const register = (kind, spec, handler) => {
|
|
12
|
+
if (sealed)
|
|
13
|
+
throw new Error("Register contributions during activate()");
|
|
14
|
+
const id = `${kind}:${++counter}`;
|
|
15
|
+
if (typeof handler === "function")
|
|
16
|
+
handlers.set(id, handler);
|
|
17
|
+
const { execute: _, ...data } = spec;
|
|
18
|
+
const record = { ...data, kind, handler: id };
|
|
19
|
+
registrations.push(record);
|
|
20
|
+
return { dispose() { handlers.delete(id); const index = registrations.indexOf(record); if (index >= 0)
|
|
21
|
+
registrations.splice(index, 1); } };
|
|
22
|
+
};
|
|
23
|
+
const call = (operation, args) => bridge(operation, args);
|
|
24
|
+
const ctx = {
|
|
25
|
+
id: initial.id, config: Object.freeze(initial.config), scope: Object.freeze(initial.scope),
|
|
26
|
+
tools: { register: (spec) => register("tool", spec, spec.execute) },
|
|
27
|
+
commands: { register: (spec) => register("command", spec, spec.execute) },
|
|
28
|
+
prompts: { register: (spec) => register("prompt", spec) },
|
|
29
|
+
skills: { register: (file) => register("skill", { file }) },
|
|
30
|
+
ui: { register: (spec) => register("ui", spec) },
|
|
31
|
+
hooks: { add: (name, handler) => register("hook", { name }, handler) },
|
|
32
|
+
events: { on: (name, handler) => register("event", { name }, handler) },
|
|
33
|
+
fs: {
|
|
34
|
+
read: (path, reason) => call("read_file", { path, reason }),
|
|
35
|
+
write: (path, content, reason) => call("write_file", { path, content, reason }),
|
|
36
|
+
list: (path, reason) => call("list_files", { path, reason }),
|
|
37
|
+
},
|
|
38
|
+
net: { fetch: (url, reason) => call("fetch", { url, reason }) },
|
|
39
|
+
process: { run: (command, reason) => call("run_command", { command, reason }) },
|
|
40
|
+
storage: { get: (key) => call("storage.get", { key }), set: (key, value) => call("storage.set", { key, value }) },
|
|
41
|
+
services: { call: (pluginId, command, args = {}) => call("service.call", { pluginId, command, args }) },
|
|
42
|
+
log: (message) => { void call("log", { message: String(message).slice(0, 4000) }).catch(() => { }); },
|
|
43
|
+
effect: (dispose) => effects.push(dispose),
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
ctx,
|
|
47
|
+
seal() { sealed = true; return registrations; },
|
|
48
|
+
async invoke(id, value, context) {
|
|
49
|
+
const handler = handlers.get(id);
|
|
50
|
+
if (!handler)
|
|
51
|
+
throw new Error("Plugin registration was disposed");
|
|
52
|
+
return await handler(value, context);
|
|
53
|
+
},
|
|
54
|
+
async dispose() { for (const dispose of effects.reverse())
|
|
55
|
+
await dispose(); handlers.clear(); registrations.length = 0; },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const pending = new Map();
|
|
59
|
+
let nextId = 0;
|
|
60
|
+
function send(value) { if (process.connected)
|
|
61
|
+
process.send?.(value); }
|
|
62
|
+
function bridge(op, value) {
|
|
63
|
+
return new Promise((resolve, reject) => { const id = ++nextId; pending.set(id, { resolve, reject }); send({ type: "operation", id, op, value }); });
|
|
64
|
+
}
|
|
65
|
+
let execute;
|
|
66
|
+
let dispose;
|
|
67
|
+
let pumping;
|
|
68
|
+
let engine;
|
|
69
|
+
let vm;
|
|
70
|
+
let cpuEnd = 0;
|
|
71
|
+
function evaluate(source) {
|
|
72
|
+
cpuEnd = Date.now() + 1000;
|
|
73
|
+
const result = vm.evalCode(source);
|
|
74
|
+
if (result.error) {
|
|
75
|
+
const error = vm.dump(result.error);
|
|
76
|
+
result.error.dispose();
|
|
77
|
+
throw new Error(typeof error?.message === "string" ? error.message : String(error));
|
|
78
|
+
}
|
|
79
|
+
result.value.dispose();
|
|
80
|
+
pump();
|
|
81
|
+
}
|
|
82
|
+
function pump() {
|
|
83
|
+
cpuEnd = Date.now() + 1000;
|
|
84
|
+
const jobs = engine?.executePendingJobs(100);
|
|
85
|
+
if (jobs && "error" in jobs && jobs.error) {
|
|
86
|
+
const error = vm.dump(jobs.error);
|
|
87
|
+
jobs.error.dispose();
|
|
88
|
+
send({ type: "fatal", error: String(error?.message ?? error) });
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
process.disconnect();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const sandboxCalls = new Map();
|
|
94
|
+
async function start(initial) {
|
|
95
|
+
if (initial.runtime === "native") {
|
|
96
|
+
const runtime = bootstrap(bridge, initial);
|
|
97
|
+
const mod = await import(pathToFileURL(initial.entry).href);
|
|
98
|
+
const plugin = mod.default ?? mod;
|
|
99
|
+
if (typeof plugin.activate !== "function")
|
|
100
|
+
throw new Error("Plugin must export activate(ctx)");
|
|
101
|
+
const cleanup = await plugin.activate(runtime.ctx);
|
|
102
|
+
if (typeof cleanup === "function")
|
|
103
|
+
runtime.ctx.effect(cleanup);
|
|
104
|
+
execute = runtime.invoke;
|
|
105
|
+
dispose = runtime.dispose;
|
|
106
|
+
send({ type: "ready", registrations: runtime.seal() });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const { getQuickJS } = await import("quickjs-emscripten");
|
|
110
|
+
const module = await getQuickJS();
|
|
111
|
+
engine = module.newRuntime();
|
|
112
|
+
engine.setMemoryLimit(32 * 1024 * 1024);
|
|
113
|
+
engine.setMaxStackSize(512 * 1024);
|
|
114
|
+
engine.setInterruptHandler(() => Date.now() >= cpuEnd);
|
|
115
|
+
vm = engine.newContext();
|
|
116
|
+
const host = vm.newFunction("__bridge", (operation, argument) => {
|
|
117
|
+
const op = vm.getString(operation), value = vm.dump(argument);
|
|
118
|
+
const deferred = vm.newPromise();
|
|
119
|
+
void bridge(op, value).then(result => {
|
|
120
|
+
if (!vm?.alive)
|
|
121
|
+
return;
|
|
122
|
+
const handle = vm.evalCode(`(${JSON.stringify(result ?? null)})`);
|
|
123
|
+
if (handle.error) {
|
|
124
|
+
deferred.reject(handle.error);
|
|
125
|
+
handle.error.dispose();
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
deferred.resolve(handle.value);
|
|
129
|
+
handle.value.dispose();
|
|
130
|
+
}
|
|
131
|
+
deferred.dispose();
|
|
132
|
+
pump();
|
|
133
|
+
}, error => {
|
|
134
|
+
if (!vm?.alive)
|
|
135
|
+
return;
|
|
136
|
+
const handle = vm.newError(String(error.message));
|
|
137
|
+
deferred.reject(handle);
|
|
138
|
+
handle.dispose();
|
|
139
|
+
deferred.dispose();
|
|
140
|
+
pump();
|
|
141
|
+
});
|
|
142
|
+
return deferred.handle;
|
|
143
|
+
});
|
|
144
|
+
vm.setProp(vm.global, "__bridge", host);
|
|
145
|
+
host.dispose();
|
|
146
|
+
const settle = vm.newFunction("__settle", (id, error, value) => {
|
|
147
|
+
const call = sandboxCalls.get(vm.getNumber(id));
|
|
148
|
+
if (call) {
|
|
149
|
+
sandboxCalls.delete(vm.getNumber(id));
|
|
150
|
+
const err = vm.dump(error);
|
|
151
|
+
if (err)
|
|
152
|
+
call.reject(new Error(String(err)));
|
|
153
|
+
else
|
|
154
|
+
call.resolve(vm.dump(value));
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
vm.setProp(vm.global, "__settle", settle);
|
|
158
|
+
settle.dispose();
|
|
159
|
+
const compiled = ts.transpileModule(fs.readFileSync(initial.entry, "utf8"), {
|
|
160
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, fileName: initial.entry,
|
|
161
|
+
}).outputText;
|
|
162
|
+
const activation = new Promise((resolve, reject) => sandboxCalls.set(0, { resolve, reject }));
|
|
163
|
+
pumping = setInterval(pump, 10);
|
|
164
|
+
pumping.unref();
|
|
165
|
+
evaluate(`globalThis.__runtime = (${bootstrap.toString()})(__bridge, ${JSON.stringify(initial)});
|
|
166
|
+
globalThis.exports = {}; globalThis.module = { exports };
|
|
167
|
+
globalThis.require = (id) => { if (id === '@xingjisoft/neutrino/plugin') return { definePlugin: p => p }; throw new Error('Sandbox imports must be bundled: ' + id); };
|
|
168
|
+
globalThis.console = { log: (...a) => __runtime.ctx.log(a.join(' ')), warn: (...a) => __runtime.ctx.log(a.join(' ')), error: (...a) => __runtime.ctx.log(a.join(' ')) };
|
|
169
|
+
${compiled}
|
|
170
|
+
globalThis.__plugin = module.exports.default || module.exports;
|
|
171
|
+
Promise.resolve().then(() => __plugin.activate(__runtime.ctx)).then(cleanup => { if (typeof cleanup === 'function') __runtime.ctx.effect(cleanup); __settle(0, null, __runtime.seal()); }, error => __settle(0, String(error), null));`);
|
|
172
|
+
const registrations = await activation;
|
|
173
|
+
execute = (handler, value, context) => new Promise((resolve, reject) => {
|
|
174
|
+
const id = ++nextId;
|
|
175
|
+
sandboxCalls.set(id, { resolve, reject });
|
|
176
|
+
try {
|
|
177
|
+
evaluate(`__runtime.invoke(${JSON.stringify(handler)}, ${JSON.stringify(value)}, ${JSON.stringify(context)}).then(v => __settle(${id}, null, v === undefined ? null : v), e => __settle(${id}, String(e), null))`);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
sandboxCalls.delete(id);
|
|
181
|
+
reject(error);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
dispose = async () => {
|
|
185
|
+
const id = ++nextId;
|
|
186
|
+
await new Promise((resolve, reject) => {
|
|
187
|
+
sandboxCalls.set(id, { resolve: () => resolve(), reject });
|
|
188
|
+
evaluate(`__runtime.dispose().then(() => __settle(${id}, null, null), e => __settle(${id}, String(e), null))`);
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
send({ type: "ready", registrations });
|
|
192
|
+
}
|
|
193
|
+
let queue = Promise.resolve();
|
|
194
|
+
process.on("message", (message) => {
|
|
195
|
+
if (message.type === "operation-result") {
|
|
196
|
+
const call = pending.get(message.id);
|
|
197
|
+
pending.delete(message.id);
|
|
198
|
+
if (message.error)
|
|
199
|
+
call?.reject(new Error(message.error));
|
|
200
|
+
else
|
|
201
|
+
call?.resolve(message.value);
|
|
202
|
+
}
|
|
203
|
+
else if (message.type === "init") {
|
|
204
|
+
queue = start(message.initial).catch(error => send({ type: "fatal", error: String(error.message) }));
|
|
205
|
+
}
|
|
206
|
+
else if (message.type === "invoke") {
|
|
207
|
+
queue = queue.then(async () => {
|
|
208
|
+
try {
|
|
209
|
+
send({ type: "result", id: message.id, value: (await execute(message.handler, message.value, message.context)) ?? null });
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
send({ type: "result", id: message.id, error: String(error.message) });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
else if (message.type === "dispose") {
|
|
217
|
+
queue = queue.finally(async () => { try {
|
|
218
|
+
await dispose?.();
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (pumping)
|
|
222
|
+
clearInterval(pumping);
|
|
223
|
+
process.disconnect();
|
|
224
|
+
} });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
process.on("disconnect", () => process.exit(0));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Shared integration used by terminal, browser, Pro and their child agents. */
|
|
2
|
+
import { type TaskRuntime } from "../llm/runtime.js";
|
|
3
|
+
import type { ApprovalDecision, ApprovalContext } from "../llm/approval.js";
|
|
4
|
+
import type { JsonObject, PluginScope } from "./types.js";
|
|
5
|
+
export declare function pluginPrompt(base: string, role: string): string;
|
|
6
|
+
/** A view can supply command arguments; quoted authorization phrases in that data are never user consent. */
|
|
7
|
+
export declare function pluginApprovalContext(context: ApprovalContext): ApprovalContext;
|
|
8
|
+
export declare function runPluginTurn(runtime: TaskRuntime, scope: PluginScope, approve: (name: string, args: JsonObject, signal?: AbortSignal) => Promise<ApprovalDecision>, body: () => Promise<void>): Promise<void>;
|
|
9
|
+
export declare function pluginCommand(request: string): {
|
|
10
|
+
name: string;
|
|
11
|
+
args: JsonObject;
|
|
12
|
+
} | null;
|