bridgerator 1.0.7 → 1.1.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 +78 -15
- package/assets/bridgerator.ico +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-20.png +0 -0
- package/assets/icon-24.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/logo.svg +1 -0
- package/install.ps1 +465 -43
- package/npm-shrinkwrap.json +8621 -0
- package/package.json +55 -49
- package/recipes/backup-folder.json +31 -0
- package/recipes/clean-downloads.json +48 -0
- package/recipes/disk-report.json +66 -0
- package/recipes/sort-folder.json +49 -0
- package/recipes/update-apps.json +25 -0
- package/src/ask-lib.ps1 +230 -0
- package/src/ask.mjs +51 -0
- package/src/ask.ps1 +17 -0
- package/src/automations.mjs +673 -0
- package/src/batch.mjs +221 -0
- package/src/bridge.mjs +743 -29
- package/src/cli.mjs +561 -53
- package/src/confirm.mjs +418 -0
- package/src/dc-config-hooks.mjs +18 -8
- package/src/dc-isolate.mjs +5 -0
- package/src/delegate.mjs +555 -0
- package/src/docs-tool.mjs +494 -0
- package/src/doctor.mjs +200 -0
- package/src/events.mjs +21 -0
- package/src/extra-tools.mjs +122 -0
- package/src/guard.mjs +34 -0
- package/src/notice.mjs +86 -23
- package/src/pair.mjs +41 -6
- package/src/pc-check.mjs +112 -0
- package/src/pc-check.ps1 +103 -0
- package/src/pc-text.mjs +94 -0
- package/src/pdf-font.mjs +245 -0
- package/src/photos-tool.mjs +187 -0
- package/src/photos.ps1 +145 -0
- package/src/plan.mjs +90 -0
- package/src/policy.mjs +293 -109
- package/src/queue.mjs +194 -0
- package/src/recent.mjs +48 -0
- package/src/recipes.mjs +108 -0
- package/src/recycle.mjs +185 -0
- package/src/recycle.ps1 +110 -0
- package/src/screen-tool.mjs +185 -0
- package/src/screen.ps1 +285 -0
- package/src/search-tool.mjs +189 -0
- package/src/search.ps1 +129 -0
- package/src/settings-tool.mjs +186 -0
- package/src/setup-tool.mjs +129 -0
- package/src/software-tool.mjs +185 -0
- package/src/telemetry.mjs +86 -0
- package/src/tool-lib.mjs +177 -0
- package/src/tool-text.mjs +45 -0
- package/src/tray.mjs +97 -0
- package/src/tray.ps1 +359 -0
- package/src/undo.mjs +313 -0
- package/src/win.mjs +17 -0
- package/ui/app.css +388 -0
- package/ui/app.js +868 -0
- package/ui/i18n/en.json +598 -0
- package/ui/i18n/ru.json +598 -0
- package/ui/index.html +26 -0
- package/vendor/webview2/LICENSE.txt +27 -0
- package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
- package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/vendor/webview2/NOTICE.txt +85 -0
- package/vendor/webview2/SHA256SUMS.txt +4 -0
- package/vendor/webview2/SOURCE.txt +1 -0
- package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
- package/vendor/webview2/x64/WebView2Loader.dll +0 -0
package/src/delegate.mjs
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("AI ↔ AI delegation"). bridgerator_delegate: one AI hands a task
|
|
2
|
+
// to another agent CLI installed on this PC — Codex (`codex exec`), Claude Code (`claude -p`), Gemini CLI
|
|
3
|
+
// (`gemini -p`) — run headless with the person's own sign-in, in ONE folder, and gets its answer back.
|
|
4
|
+
// - folder rules: a full local path inside the allowed list, not excluded, not holding an excluded /
|
|
5
|
+
// Bridgerator folder; a read-only folder (or one holding a read-only folder) → the agent runs in "read";
|
|
6
|
+
// - limits (window "Agents" / bridgerator_settings `delegate.*`): per-agent allow (on once found), mode
|
|
7
|
+
// read / edit / full (default full = the agent's own permissions widest inside the folder), at most
|
|
8
|
+
// `parallel` runs, `timeoutMin`, `maxOutputKb` of answer;
|
|
9
|
+
// - Undo: the folder is copied into the snapshots before the run (≤ undoMb, ≤ 5000 files; .git,
|
|
10
|
+
// node_modules, .venv skipped), after it only the changed files are kept → ONE undo action per run;
|
|
11
|
+
// - the child gets no Bridgerator variables / tokens, no MCP servers (no way back into Bridgerator:
|
|
12
|
+
// a delegated agent can not delegate again), deny rules for Bridgerator's folders where the CLI has them;
|
|
13
|
+
// - the task text is checked like a terminal command (excluded folders, Bridgerator itself) and must not
|
|
14
|
+
// carry this PC's key; the answer has the key masked;
|
|
15
|
+
// - finished background runs → relay event `delegate_done` {ok: 0|1} (events.mjs).
|
|
16
|
+
// Detection reads only the FACT that a CLI's sign-in file exists (never its content) and runs `--version`.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { isSea } from 'node:sea';
|
|
23
|
+
import { stripOurs } from './ask.mjs';
|
|
24
|
+
import { writeAtomic } from './confirm.mjs';
|
|
25
|
+
import { norm, realish, ownDenied, sensitiveDenied } from './policy.mjs';
|
|
26
|
+
import { fail, json, needAction, str, int, guard } from './tool-lib.mjs';
|
|
27
|
+
import { TASKKILL } from './win.mjs';
|
|
28
|
+
|
|
29
|
+
export const AGENTS = {
|
|
30
|
+
codex: { name: 'Codex', bin: 'codex' },
|
|
31
|
+
claude: { name: 'Claude Code', bin: 'claude' },
|
|
32
|
+
gemini: { name: 'Gemini CLI', bin: 'gemini' },
|
|
33
|
+
};
|
|
34
|
+
export const AGENT_IDS = Object.keys(AGENTS);
|
|
35
|
+
export const MODES = ['read', 'edit', 'full']; // narrow → wide
|
|
36
|
+
export const NO_MCP = 'bridgerator-none'; // Gemini: allow only this (non-existent) MCP server
|
|
37
|
+
const MB = 1024 * 1024;
|
|
38
|
+
const MAX_TASK = 16000, MAX_RUNS = 50, MAX_FILES_LISTED = 200, SCAN_FILES = 20000, SNAP_FILES = 5000;
|
|
39
|
+
const STDOUT_CAP = 4 * MB, STDERR_CAP = 16 * 1024, VERSION_MS = 15000, DETECT_TTL_MS = 10 * 60000;
|
|
40
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__']);
|
|
41
|
+
const STATES = ['running', 'done', 'failed', 'timeout', 'cancelled', 'interrupted'];
|
|
42
|
+
|
|
43
|
+
// ---- settings (extra-tools registry: window page "Agents", bridgerator_settings `delegate`) ----------
|
|
44
|
+
const lab = (en, ru) => ({ en, ru });
|
|
45
|
+
export const settings = [
|
|
46
|
+
...AGENT_IDS.map((id) => ({ key: `delegate.${id}`, default: true, widensWhen: true, page: 'agents', label: lab(`Allow delegation to ${AGENTS[id].name}`, `Разрешить поручать задачи ${AGENTS[id].name}`), hint: lab('Used only when it is installed on this PC', 'Работает, только если он установлен на этом ПК') })),
|
|
47
|
+
{ key: 'delegate.mode', type: 'enum', values: MODES, default: 'full', widensWhen: 'up', page: 'agents', label: lab('What a delegated agent may do (read / edit / full)', 'Что может делать агент (read / edit / full)'), hint: lab('full = its own widest permissions inside the chosen folder', 'full = самые широкие права самого агента внутри выбранной папки') },
|
|
48
|
+
{ key: 'delegate.parallel', type: 'int', min: 1, max: 4, default: 2, widensWhen: 'up', page: 'agents', label: lab('Delegated tasks at once', 'Поручений одновременно') },
|
|
49
|
+
{ key: 'delegate.timeoutMin', type: 'int', min: 1, max: 240, default: 30, widensWhen: 'up', page: 'agents', label: lab('Time limit per task (minutes)', 'Лимит времени на задачу (минуты)') },
|
|
50
|
+
{ key: 'delegate.maxOutputKb', type: 'int', min: 4, max: 256, default: 32, widensWhen: null, page: 'agents', label: lab('Longest answer passed back (KB)', 'Самый длинный ответ (КБ)') },
|
|
51
|
+
// Owner 21.10: the window's "Agent tasks" page keeps finished tasks (newest first, with Undo); off = finished ones disappear.
|
|
52
|
+
{ key: 'delegate.keepFinished', default: true, widensWhen: false, label: lab('Keep finished agent tasks in the list', 'Хранить завершённые задачи агентов'), hint: lab('On the Agent tasks page, newest first, each with Undo. Off: a task leaves the list when it ends.', 'На странице «Задачи агентов», новые сверху, у каждой есть отмена. Выключено: задача пропадает из списка, когда закончится.') },
|
|
53
|
+
{ key: 'delegate.undoMb', type: 'int', min: 0, max: 2000, default: 200, widensWhen: 'down', page: 'agents', label: lab('Copy the folder for Undo up to (MB, 0 = no copy)', 'Копия папки для отмены до (МБ, 0 = без копии)') },
|
|
54
|
+
];
|
|
55
|
+
const SPEC = new Map(settings.map((s) => [s.key.split('.')[1], s]));
|
|
56
|
+
const okVal = (s, v) => (s.type === 'int' ? Number.isInteger(v) && v >= s.min && v <= s.max : s.type === 'enum' ? s.values.includes(v) : typeof v === 'boolean');
|
|
57
|
+
/** Current delegate.* values of a config (bad / missing → the default). */
|
|
58
|
+
export function limitsOf(cfg) {
|
|
59
|
+
const o = {};
|
|
60
|
+
for (const [k, s] of SPEC) { const v = cfg?.delegate?.[k]; o[k] = okVal(s, v) ? v : s.default; }
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- finding the CLIs --------------------------------------------------------------------------------
|
|
65
|
+
const envGet = (env, n) => { const k = Object.keys(env || {}).find((x) => x.toLowerCase() === n.toLowerCase()); return k ? env[k] : undefined; };
|
|
66
|
+
const homeOf = (env, home) => home || envGet(env, 'USERPROFILE') || os.homedir();
|
|
67
|
+
const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
68
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
69
|
+
|
|
70
|
+
/** Folders searched for an agent, known install places first, then PATH (absolute, not the current / home folder). */
|
|
71
|
+
export function searchDirs(id, { env = process.env, home, cwd = process.cwd() } = {}) {
|
|
72
|
+
const h = homeOf(env, home);
|
|
73
|
+
const appData = envGet(env, 'APPDATA') || path.join(h, 'AppData', 'Roaming');
|
|
74
|
+
const local = envGet(env, 'LOCALAPPDATA') || path.join(h, 'AppData', 'Local');
|
|
75
|
+
const bin = AGENTS[id].bin;
|
|
76
|
+
const known = [path.join(appData, 'npm'), path.join(h, '.local', 'bin'), path.join(local, 'Microsoft', 'WinGet', 'Links'), path.join(local, 'pnpm'), path.join(local, 'Programs', bin), path.join(local, 'Programs', bin, 'bin')];
|
|
77
|
+
const bad = new Set([norm(cwd), norm(h)]);
|
|
78
|
+
const fromPath = String(envGet(env, 'PATH') || '').split(';').map((d) => d.trim().replace(/^"(.*)"$/, '$1')).filter((d) => /^[a-z]:[\\/]/i.test(d) && !bad.has(norm(d)));
|
|
79
|
+
const seen = new Set(), out = [];
|
|
80
|
+
for (const d of [...known, ...fromPath]) { const k = norm(d); if (!seen.has(k)) { seen.add(k); out.push(d); } }
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An npm shim (`codex.cmd`) → what it starts: { cmd, pre } for a node script (run with OUR node, never
|
|
86
|
+
* through cmd.exe: a prompt must not pass a shell) or an exe, or null when the shim is not npm's.
|
|
87
|
+
*/
|
|
88
|
+
export function readShim(file, { sea = isSea(), env = process.env } = {}) {
|
|
89
|
+
let t;
|
|
90
|
+
try { t = fs.readFileSync(file, 'utf8').slice(0, 65536); } catch { return null; }
|
|
91
|
+
const all = [...t.matchAll(/"%~?dp0%\\?([^"%*\r\n]+?\.(?:c?js|mjs|exe))"/gi)].map((m) => m[1]).filter((r) => !/^node\.exe$/i.test(r));
|
|
92
|
+
if (!all.length) return null;
|
|
93
|
+
const dir = path.dirname(file), target = path.resolve(dir, all[all.length - 1]);
|
|
94
|
+
if (!target.startsWith(dir + path.sep) || !isFile(target)) return null;
|
|
95
|
+
if (/\.exe$/i.test(target)) return { cmd: target, pre: [] };
|
|
96
|
+
// The Windows app (SEA): process.execPath is bridgerator.exe, which runs only its own app's scripts →
|
|
97
|
+
// the person's own Node (the one npm installed the agent with), by absolute path; none → not startable.
|
|
98
|
+
const node = sea ? nodeExe(dir, env) : process.execPath;
|
|
99
|
+
return node ? { cmd: node, pre: [target] } : null;
|
|
100
|
+
}
|
|
101
|
+
/** node.exe for an npm shim in `dir`: next to it, Program Files\nodejs, then absolute PATH folders. */
|
|
102
|
+
export function nodeExe(dir, env = process.env) {
|
|
103
|
+
const pathVar = env.Path ?? env.PATH ?? '';
|
|
104
|
+
const dirs = [dir, ...[env.ProgramFiles, env['ProgramFiles(x86)']].filter(Boolean).map((d) => path.join(d, 'nodejs')), ...pathVar.split(';').map((d) => d.trim().replace(/^"|"$/g, '')).filter((d) => /^[a-z]:\\/i.test(d))];
|
|
105
|
+
for (const d of dirs) { const f = path.join(d, 'node.exe'); if (isFile(f)) return f; }
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** → { path, cmd, pre } (cmd null + problem when found but not startable safely) or null. */
|
|
110
|
+
export function findAgent(id, opts = {}) {
|
|
111
|
+
const bin = AGENTS[id].bin;
|
|
112
|
+
let odd = null;
|
|
113
|
+
for (const d of searchDirs(id, opts)) {
|
|
114
|
+
const exe = path.join(d, `${bin}.exe`);
|
|
115
|
+
if (isFile(exe)) return { path: exe, cmd: exe, pre: [] };
|
|
116
|
+
const shim = path.join(d, `${bin}.cmd`);
|
|
117
|
+
if (isFile(shim)) {
|
|
118
|
+
const r = readShim(shim);
|
|
119
|
+
if (r) return { path: shim, ...r };
|
|
120
|
+
odd ||= { path: shim, cmd: null, pre: [], problem: 'SHIM' };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return odd;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Signed in? 'yes' | 'no' | 'unknown' — only whether the CLI's sign-in file / key variable exists. */
|
|
127
|
+
export function signedIn(id, { env = process.env, home } = {}) {
|
|
128
|
+
const h = homeOf(env, home);
|
|
129
|
+
const set = (...n) => n.some((x) => !!envGet(env, x));
|
|
130
|
+
if (id === 'codex') {
|
|
131
|
+
const d = envGet(env, 'CODEX_HOME') || path.join(h, '.codex');
|
|
132
|
+
if (isFile(path.join(d, 'auth.json')) || set('CODEX_API_KEY', 'OPENAI_API_KEY')) return 'yes';
|
|
133
|
+
let t = '';
|
|
134
|
+
try { t = fs.readFileSync(path.join(d, 'config.toml'), 'utf8'); } catch { /* no config */ }
|
|
135
|
+
return /^\s*cli_auth_credentials_store\s*=\s*"(keyring|auto)"/m.test(t) ? 'unknown' : 'no'; // kept in the Windows keyring
|
|
136
|
+
}
|
|
137
|
+
if (id === 'claude') {
|
|
138
|
+
const d = envGet(env, 'CLAUDE_CONFIG_DIR') || path.join(h, '.claude');
|
|
139
|
+
return isFile(path.join(d, '.credentials.json')) || set('ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX') ? 'yes' : 'no';
|
|
140
|
+
}
|
|
141
|
+
return isFile(path.join(h, '.gemini', 'oauth_creds.json')) || set('GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS') ? 'yes' : 'no';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Codex MCP servers of the person's config.toml (names only) → switched off for a delegated run. */
|
|
145
|
+
export function codexMcpNames({ env = process.env, home } = {}) {
|
|
146
|
+
const d = envGet(env, 'CODEX_HOME') || path.join(homeOf(env, home), '.codex');
|
|
147
|
+
let t = '';
|
|
148
|
+
try { t = fs.readFileSync(path.join(d, 'config.toml'), 'utf8'); } catch { return []; }
|
|
149
|
+
const names = [...t.matchAll(/^\s*\[mcp_servers\.(?:"([^"\]]+)"|([A-Za-z0-9_-]+))\]\s*$/gm)].map((m) => m[1] || m[2]).filter((n) => /^[A-Za-z0-9_-]{1,64}$/.test(n));
|
|
150
|
+
return [...new Set(names)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The environment of a delegated run: the person's, without Bridgerator's variables / key. */
|
|
154
|
+
export function childEnv(env = process.env, token = '') {
|
|
155
|
+
const e = stripOurs(env);
|
|
156
|
+
for (const k of Object.keys(e)) {
|
|
157
|
+
if (/^(NODE_TLS_REJECT_UNAUTHORIZED|DC_.*|DESKTOP_COMMANDER_.*)$/i.test(k)) delete e[k];
|
|
158
|
+
else if (/^NODE_OPTIONS$/i.test(k) && /bridgerator/i.test(e[k])) delete e[k];
|
|
159
|
+
else if (token && token.length >= 8 && String(e[k]).includes(token)) delete e[k];
|
|
160
|
+
}
|
|
161
|
+
return e;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- the command line of each agent --------------------------------------------------------------------
|
|
165
|
+
/** C:\Users\A\x → //c/Users/A/x (Claude Code permission rules match POSIX-style absolute paths on Windows). */
|
|
166
|
+
export const claudePath = (p) => { const m = /^([a-z]):[\\/]?(.*)$/i.exec(p); return m ? `//${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/').replace(/\/+$/, '')}` : p.replace(/\\/g, '/'); };
|
|
167
|
+
|
|
168
|
+
export function promptFor({ task, folder, from }) {
|
|
169
|
+
return [
|
|
170
|
+
`A task handed to you by ${from} (another AI assistant this person uses) through a local bridge on this PC.`,
|
|
171
|
+
`Work only inside this folder: ${folder}`,
|
|
172
|
+
'Do not hand the task to another AI agent and do not change any AI assistant settings.',
|
|
173
|
+
'When you finish, answer with a short summary of what you did and the files you created, changed or deleted.',
|
|
174
|
+
'',
|
|
175
|
+
'Task:',
|
|
176
|
+
task,
|
|
177
|
+
].join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** argv for the agent CLI (headless, JSON output, permissions by mode, no MCP servers). */
|
|
181
|
+
export function buildArgs(id, { prompt, folder, mode, denyDirs = [], mcpNames = [] }) {
|
|
182
|
+
if (!MODES.includes(mode)) throw new Error(`bad mode ${mode}`);
|
|
183
|
+
if (id === 'codex') {
|
|
184
|
+
// workspace-write: writes only inside --cd (and temp); full = also network for its commands.
|
|
185
|
+
const a = ['exec', '--json', '--skip-git-repo-check', '--cd', folder, '--sandbox', mode === 'read' ? 'read-only' : 'workspace-write'];
|
|
186
|
+
if (mode === 'full') a.push('-c', 'sandbox_workspace_write.network_access=true');
|
|
187
|
+
for (const n of mcpNames) a.push('-c', `mcp_servers.${n}.enabled=false`);
|
|
188
|
+
return [...a, '--', prompt];
|
|
189
|
+
}
|
|
190
|
+
if (id === 'claude') {
|
|
191
|
+
const pm = { read: 'dontAsk', edit: 'acceptEdits', full: 'bypassPermissions' }[mode];
|
|
192
|
+
// Deny rules hold in every mode, bypassPermissions too; "mcp__*" removes every MCP tool (no way back to Bridgerator).
|
|
193
|
+
const rules = ['mcp__*'];
|
|
194
|
+
for (const d of denyDirs) { const p = claudePath(d); rules.push(...(isDir(d) ? [`Read(${p}/**)`, `Edit(${p}/**)`] : [`Read(${p})`, `Edit(${p})`])); }
|
|
195
|
+
return ['-p', '--output-format', 'json', '--permission-mode', pm, '--disallowedTools', ...rules, '--', prompt];
|
|
196
|
+
}
|
|
197
|
+
if (id === 'gemini') {
|
|
198
|
+
return ['--output-format', 'json', ...(mode === 'read' ? [] : ['--approval-mode', mode === 'full' ? 'yolo' : 'auto_edit']), '--allowed-mcp-server-names', NO_MCP, `--prompt=${prompt}`];
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`unknown agent ${id}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const tail = (s, n = 4000) => { const t = String(s || '').trim(); return t.length > n ? `…${t.slice(-n)}` : t; };
|
|
204
|
+
function lastJson(s) {
|
|
205
|
+
const t = String(s || '').trim();
|
|
206
|
+
try { return JSON.parse(t); } catch { /* not one object */ }
|
|
207
|
+
const lines = t.split(/\r?\n/);
|
|
208
|
+
for (let i = lines.length - 1; i >= 0; i--) { const l = lines[i].trim(); if (l.startsWith('{')) { try { return JSON.parse(l); } catch { /* next */ } } }
|
|
209
|
+
const i = t.indexOf('{');
|
|
210
|
+
if (i > 0) { try { return JSON.parse(t.slice(i)); } catch { /* none */ } }
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/** stdout of the agent → { text, error } (error = the agent's own failure message, else null). */
|
|
214
|
+
export function parseOutput(id, out) {
|
|
215
|
+
const s = String(out || '');
|
|
216
|
+
if (id === 'codex') {
|
|
217
|
+
let text = '', error = null;
|
|
218
|
+
for (const line of s.split(/\r?\n/)) {
|
|
219
|
+
if (!line.startsWith('{')) continue;
|
|
220
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
221
|
+
if (e.type === 'item.completed' && e.item?.type === 'agent_message' && typeof e.item.text === 'string') text = e.item.text;
|
|
222
|
+
else if (e.type === 'turn.failed') error = String(e.error?.message || 'the turn failed');
|
|
223
|
+
else if (e.type === 'error' && e.message) error = String(e.message);
|
|
224
|
+
}
|
|
225
|
+
return { text: text || (error ? '' : tail(s)), error };
|
|
226
|
+
}
|
|
227
|
+
const j = lastJson(s);
|
|
228
|
+
if (!j || typeof j !== 'object') return { text: tail(s), error: null };
|
|
229
|
+
if (id === 'claude') {
|
|
230
|
+
const bad = j.is_error === true || (typeof j.subtype === 'string' && j.subtype !== 'success');
|
|
231
|
+
const text = typeof j.result === 'string' ? j.result : '';
|
|
232
|
+
return { text, error: bad ? (text.slice(0, 300) || String(j.subtype || 'error')) : null };
|
|
233
|
+
}
|
|
234
|
+
return { text: typeof j.response === 'string' ? j.response : '', error: j.error ? String(j.error.message || j.error.type || 'error') : null };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- the folder before / after ---------------------------------------------------------------------------
|
|
238
|
+
/** → { over, files: Map(path → {size, mtimeMs}) | null, bytes }; null when the folder can't be read. */
|
|
239
|
+
export function scanTree(root, { maxFiles = SCAN_FILES } = {}) {
|
|
240
|
+
const files = new Map();
|
|
241
|
+
let bytes = 0;
|
|
242
|
+
const stack = [root];
|
|
243
|
+
while (stack.length) {
|
|
244
|
+
const d = stack.pop();
|
|
245
|
+
let list;
|
|
246
|
+
try { list = fs.readdirSync(d, { withFileTypes: true }); } catch { if (d === root) return null; continue; }
|
|
247
|
+
for (const e of list) {
|
|
248
|
+
if (e.isSymbolicLink()) continue; // junctions too: never followed
|
|
249
|
+
const p = path.join(d, e.name);
|
|
250
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name.toLowerCase())) stack.push(p); continue; }
|
|
251
|
+
if (!e.isFile()) continue;
|
|
252
|
+
if (files.size >= maxFiles) return { over: true, files: null, bytes };
|
|
253
|
+
let st; try { st = fs.statSync(p); } catch { continue; }
|
|
254
|
+
files.set(p, { size: st.size, mtimeMs: Math.round(st.mtimeMs) });
|
|
255
|
+
bytes += st.size;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { over: false, files, bytes };
|
|
259
|
+
}
|
|
260
|
+
export function diffTrees(a, b) {
|
|
261
|
+
const modified = [], created = [], deleted = [];
|
|
262
|
+
for (const [p, x] of a) { const y = b.get(p); if (!y) deleted.push(p); else if (y.size !== x.size || y.mtimeMs !== x.mtimeMs) modified.push(p); }
|
|
263
|
+
for (const p of b.keys()) if (!a.has(p)) created.push(p);
|
|
264
|
+
return { modified: modified.sort(), created: created.sort(), deleted: deleted.sort() };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The ONE place an agent CLI starts: a.cmd is an absolute path from findAgent (an .exe, or our node for an npm script), never a shell. */
|
|
268
|
+
export const startCli = (a, args, opts) => spawn(a.cmd, [...a.pre, ...args], opts);
|
|
269
|
+
|
|
270
|
+
export function killTree(child) {
|
|
271
|
+
if (!child || child.exitCode !== null) return;
|
|
272
|
+
if (process.platform === 'win32' && child.pid) {
|
|
273
|
+
const k = spawn(TASKKILL, ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
274
|
+
k.on('error', () => { try { child.kill(); } catch { /* gone */ } });
|
|
275
|
+
} else { try { child.kill('SIGKILL'); } catch { /* gone */ } }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- runs -----------------------------------------------------------------------------------------------
|
|
279
|
+
const cleanName = (v) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 80) : '');
|
|
280
|
+
const cap = (s, n) => (s.length <= n ? s : `${s.slice(0, Math.floor(n / 4))}\n…[${s.length - n} characters left out]…\n${s.slice(-(n - Math.floor(n / 4)))}`);
|
|
281
|
+
const mask = (s, token) => (token && token.length >= 8 ? String(s).split(token).join('[hidden]') : String(s));
|
|
282
|
+
const capList = (l) => (l.length > MAX_FILES_LISTED ? [...l.slice(0, MAX_FILES_LISTED), `… and ${l.length - MAX_FILES_LISTED} more`] : l);
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The run store of one bridge. file = the run log (delegate.json in the settings folder, local only).
|
|
286
|
+
* snapshots = undo.mjs store (or null). emit({kind,…}) → relay events. Test seams: env, home, minuteMs, kill.
|
|
287
|
+
*/
|
|
288
|
+
export function createDelegate({ file = '', snapshots = null, log = () => {}, emit = () => {}, onChange = () => {}, env = process.env, home, now = Date.now, minuteMs = 60000, kill = killTree } = {}) {
|
|
289
|
+
let runs = [];
|
|
290
|
+
try {
|
|
291
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
292
|
+
if (Array.isArray(j.runs)) runs = j.runs.filter((r) => r && typeof r.id === 'string' && STATES.includes(r.state)).slice(0, MAX_RUNS);
|
|
293
|
+
} catch { /* no log yet */ }
|
|
294
|
+
// A run that was going when the bridge stopped is not watched any more.
|
|
295
|
+
for (const r of runs) if (r.state === 'running') { r.state = 'interrupted'; r.ok = false; r.endedAt ||= new Date(now()).toISOString(); }
|
|
296
|
+
const live = new Map(); // id → { child, done: Promise, cancel() }
|
|
297
|
+
let detected = null, detecting = null;
|
|
298
|
+
const save = () => { if (!file) return; try { writeAtomic(file, JSON.stringify({ runs })); } catch (e) { log(`[bridgerator] delegate log not saved: ${e.code || e.message}`); } };
|
|
299
|
+
|
|
300
|
+
function version(a) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
let out = '', done = false;
|
|
303
|
+
const fin = (v) => { if (!done) { done = true; clearTimeout(t); resolve(v); } };
|
|
304
|
+
let c;
|
|
305
|
+
try { c = startCli(a, ['--version'], { windowsHide: true, env: childEnv(env), stdio: ['ignore', 'pipe', 'ignore'] }); } catch { return resolve(null); }
|
|
306
|
+
const t = setTimeout(() => { kill(c); fin(null); }, VERSION_MS);
|
|
307
|
+
c.stdout.on('data', (b) => { if (out.length < 2000) out += b; });
|
|
308
|
+
c.on('error', () => fin(null));
|
|
309
|
+
c.on('close', () => { const m = /\d+\.\d+(?:\.\d+)?[\w.+-]*/.exec(out); fin(m ? m[0].slice(0, 40) : null); });
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async function detect(force = false) {
|
|
313
|
+
if (!force && detected && now() - detected.t < DETECT_TTL_MS) return detected.list;
|
|
314
|
+
if (detecting) return detecting;
|
|
315
|
+
detecting = (async () => {
|
|
316
|
+
const list = [];
|
|
317
|
+
for (const id of AGENT_IDS) {
|
|
318
|
+
const f = findAgent(id, { env, home });
|
|
319
|
+
const e = { id, name: AGENTS[id].name, found: !!f, path: f?.path || null, startable: !!f?.cmd, version: null, signedIn: f ? signedIn(id, { env, home }) : 'no' };
|
|
320
|
+
if (f?.cmd) e.version = await version(f);
|
|
321
|
+
e._run = f?.cmd ? { cmd: f.cmd, pre: f.pre } : null;
|
|
322
|
+
list.push(e);
|
|
323
|
+
}
|
|
324
|
+
detected = { t: now(), list };
|
|
325
|
+
onChange();
|
|
326
|
+
return list;
|
|
327
|
+
})().finally(() => { detecting = null; });
|
|
328
|
+
return detecting;
|
|
329
|
+
}
|
|
330
|
+
const publicAgent = ({ _run, ...a }) => a;
|
|
331
|
+
|
|
332
|
+
const slim = (r) => ({
|
|
333
|
+
id: r.id, agent: r.agent, name: AGENTS[r.agent]?.name || r.agent, from: r.from, folder: r.folder, mode: r.mode, task: r.task,
|
|
334
|
+
startedAt: r.startedAt, endedAt: r.endedAt || null, state: r.state, ok: !!r.ok, summary: r.summary || '',
|
|
335
|
+
changed: r.changed ? { modified: r.changed.modified.length, created: r.changed.created.length, deleted: r.changed.deleted.length } : null,
|
|
336
|
+
undoId: r.undoId || null, undone: !!(r.undoId && snapshots?.find(r.undoId)?.undone),
|
|
337
|
+
});
|
|
338
|
+
function resultView(r) {
|
|
339
|
+
const v = { id: r.id, agent: r.agent, state: r.state, ok: !!r.ok, from: r.from, folder: r.folder, mode: r.mode, seconds: r.endedAt ? Math.round((Date.parse(r.endedAt) - Date.parse(r.startedAt)) / 1000) : null };
|
|
340
|
+
if (r.state === 'running') return { ...v, note: 'Still running. Ask again with action "status" or "result" (same id) in a minute.' };
|
|
341
|
+
if (r.exitCode !== undefined) v.exitCode = r.exitCode;
|
|
342
|
+
v.answer = r.text || '';
|
|
343
|
+
if (r.error) v.error = r.error;
|
|
344
|
+
v.changedFiles = r.changed ? { modified: capList(r.changed.modified), created: capList(r.changed.created), deleted: capList(r.changed.deleted) } : `not checked (${r.changedWhy || 'unknown'})`;
|
|
345
|
+
v.undo = r.undoId ? (slim(r).undone ? 'already undone' : `bridgerator_undo id ${r.undoId} undoes every file change of this run`) : (r.undoWhy || 'no file changes to undo');
|
|
346
|
+
if (r.notes?.length) v.notes = r.notes;
|
|
347
|
+
return v;
|
|
348
|
+
}
|
|
349
|
+
const find = (id) => { const r = runs.find((x) => x.id === id); if (!r) fail('NO_RUN', `No delegated run ${id} on this PC.`, 'action "status" lists the recent ones.'); return r; };
|
|
350
|
+
|
|
351
|
+
async function run(ctx, args) {
|
|
352
|
+
const id = args.agent;
|
|
353
|
+
if (!AGENT_IDS.includes(id)) fail('BAD_ARGS', `agent must be one of: ${AGENT_IDS.join(', ')}.`);
|
|
354
|
+
const A = AGENTS[id];
|
|
355
|
+
const task = str(args, 'task', { required: true, max: MAX_TASK }).trim();
|
|
356
|
+
if (!task || /\u0000/.test(task)) fail('BAD_ARGS', 'task must be text.');
|
|
357
|
+
const from = cleanName(args.from) || 'the AI';
|
|
358
|
+
const waitS = int(args, 'wait_seconds', { min: 0, max: 240, def: 45 });
|
|
359
|
+
if (args.mode !== undefined && !MODES.includes(args.mode)) fail('BAD_ARGS', `mode must be one of: ${MODES.join(', ')}.`);
|
|
360
|
+
const L = limitsOf(ctx.cfg);
|
|
361
|
+
if (!L[id]) fail('AGENT_OFF', `Delegation to ${A.name} is turned off on this PC.`, `The person can turn it on in Bridgerator → Agents, or you can ask with bridgerator_settings (delegate.${id}: true).`);
|
|
362
|
+
if (ctx.policy && !ctx.policy.visible({ name: 'start_process' })) fail('TERMINAL_OFF', 'Running programs is off on this PC (access preset Read only or Files), so no agent can be started.', 'The person can switch to Full in Bridgerator → Safety.');
|
|
363
|
+
// The folder: same rules as the file tools; it must not hold anything excluded (the agent works on the whole tree).
|
|
364
|
+
// (guard tree: FOLDER_HAS_EXCLUDED when it contains an excluded / Bridgerator folder)
|
|
365
|
+
const folder = guard(ctx, str(args, 'folder', { required: true, max: 1000 }), { tree: true });
|
|
366
|
+
if (!isDir(folder)) fail('NO_FOLDER', `"${folder}" is not an existing folder.`, 'Give the full path of the folder the agent works in.');
|
|
367
|
+
const nf = norm(realish(folder));
|
|
368
|
+
const notes = [];
|
|
369
|
+
let mode = args.mode || L.mode;
|
|
370
|
+
if (MODES.indexOf(mode) > MODES.indexOf(L.mode)) { notes.push(`mode lowered to "${L.mode}" (the limit set on this PC)`); mode = L.mode; }
|
|
371
|
+
const roInside = (ctx.cfg?.readOnlyDirectories || []).some((d) => norm(realish(d)).startsWith(nf + path.sep));
|
|
372
|
+
if (mode !== 'read' && (ctx.policy?.writable(folder) || roInside)) { notes.push('mode "read": the folder is (or holds) a read-only folder'); mode = 'read'; }
|
|
373
|
+
// Never this PC's key in the task; the text is checked like a terminal command (excluded folders, Bridgerator itself).
|
|
374
|
+
for (const s of [ctx.cfg?.token, ctx.cfg?.installId]) if (typeof s === 'string' && s.length >= 8 && task.includes(s)) fail('SECRET_IN_TASK', 'The task contains this PC\'s Bridgerator key.', 'Leave it out: the agent does not need it.');
|
|
375
|
+
const why = ctx.policy?.check('start_process', { command: task });
|
|
376
|
+
if (why) fail('ACCESS_DENIED', why.replace(/^Access denied by Bridgerator: /, '').replace(/the command/, 'the task'), 'Change the task text.');
|
|
377
|
+
const agent = (await detect()).find((x) => x.id === id);
|
|
378
|
+
if (!agent?.found) fail('NOT_FOUND', `${A.name} is not installed on this PC (or Bridgerator did not find it).`, `Install it and sign in once in a terminal, then try again; action "agents" shows what was found.`);
|
|
379
|
+
if (!agent._run) fail('CANT_START', `${A.name} was found (${agent.path}) but Bridgerator can't start it safely (not a standard npm or .exe install).`, 'Reinstall it with npm or its own installer.');
|
|
380
|
+
if (agent.signedIn === 'no') notes.push(`${A.name} looks signed out on this PC — if it fails, the person signs in once in a terminal ("${A.bin}")`);
|
|
381
|
+
const busy = () => {
|
|
382
|
+
if (live.size >= L.parallel) fail('BUSY', `${live.size} delegated task${live.size === 1 ? ' is' : 's are'} already running (limit ${L.parallel}).`, 'Wait for one to finish (action "status"), cancel one, or ask the person to raise the limit.');
|
|
383
|
+
// Two runs in the same or nested folders would mix their change lists and Undo.
|
|
384
|
+
const near = runs.find((r) => r.state === 'running' && live.has(r.id) && r.folder && (() => { const o = norm(realish(r.folder)); return o === nf || o.startsWith(nf + path.sep) || nf.startsWith(o + path.sep); })());
|
|
385
|
+
if (near) fail('FOLDER_BUSY', `Delegated run ${near.id} is already working in "${near.folder}", the same folder or one inside / around this one.`, 'Wait for it to finish (action "status"), or give this task another folder.');
|
|
386
|
+
};
|
|
387
|
+
busy();
|
|
388
|
+
const stop = await ctx.gate?.gate?.('start_process', { command: `${A.bin} (task from ${from}) in ${folder}` });
|
|
389
|
+
if (stop) return stop;
|
|
390
|
+
busy(); // another run may have started meanwhile
|
|
391
|
+
const rid = `d${now().toString(36)}${crypto.randomBytes(2).toString('hex')}`;
|
|
392
|
+
const rec = { id: rid, agent: id, from, folder, mode, task: task.slice(0, 300), startedAt: new Date(now()).toISOString(), state: 'running', ok: false };
|
|
393
|
+
const entry = { child: null, canceled: false, timedOut: false };
|
|
394
|
+
live.set(rid, entry);
|
|
395
|
+
runs.unshift(rec); runs = runs.slice(0, MAX_RUNS); save(); onChange();
|
|
396
|
+
|
|
397
|
+
let resolveDone, finished = false;
|
|
398
|
+
entry.done = new Promise((r) => { resolveDone = r; });
|
|
399
|
+
(async () => {
|
|
400
|
+
// Undo: the folder as it is now (only when it fits the limits), later only what the agent changed.
|
|
401
|
+
const before = scanTree(folder);
|
|
402
|
+
let pend = null;
|
|
403
|
+
if (!before) rec.changedWhy = 'the folder could not be read';
|
|
404
|
+
else if (before.over) rec.changedWhy = `more than ${SCAN_FILES} files`;
|
|
405
|
+
if (!snapshots || snapshots.enabled === false) rec.undoWhy = 'Undo is off on this PC (snapshots)';
|
|
406
|
+
else if (L.undoMb === 0) rec.undoWhy = 'no copy for Undo (delegate.undoMb is 0)';
|
|
407
|
+
else if (!before || before.over || before.files.size > SNAP_FILES || before.bytes > L.undoMb * MB) rec.undoWhy = `no copy for Undo: the folder is bigger than ${L.undoMb} MB / ${SNAP_FILES} files`;
|
|
408
|
+
else {
|
|
409
|
+
pend = await snapshots.capture('delegate', [...before.files.keys()].map((p) => ({ path: p })), { label: `${A.name}: ${task.slice(0, 120)}`, keepEmpty: true, maxMs: (L.timeoutMin + 5) * 60000 }).catch((e) => { log(`[bridgerator] delegate snapshot skipped: ${e.code || e.message}`); return null; });
|
|
410
|
+
if (!pend) rec.undoWhy = 'Undo is off on this PC (snapshots)';
|
|
411
|
+
}
|
|
412
|
+
if (entry.canceled) { snapshots?.commit(pend, false); return finish(null, '', ''); }
|
|
413
|
+
const argv = buildArgs(id, { prompt: promptFor({ task, folder, from }), folder, mode, denyDirs: denyDirsOf(ctx.cfg), mcpNames: id === 'codex' ? codexMcpNames({ env, home }) : [] });
|
|
414
|
+
const out = [], err = [];
|
|
415
|
+
let outBytes = 0, errBytes = 0;
|
|
416
|
+
let child;
|
|
417
|
+
try { child = startCli(agent._run, argv, { cwd: folder, env: childEnv(env, ctx.cfg?.token), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return finish(e, '', '', before, pend); }
|
|
418
|
+
entry.child = child;
|
|
419
|
+
const timer = setTimeout(() => { entry.timedOut = true; kill(child); }, L.timeoutMin * minuteMs);
|
|
420
|
+
child.stdout.on('data', (b) => { out.push(b); outBytes += b.length; while (outBytes > STDOUT_CAP && out.length > 1) outBytes -= out.shift().length; });
|
|
421
|
+
child.stderr.on('data', (b) => { err.push(b); errBytes += b.length; while (errBytes > STDERR_CAP && err.length > 1) errBytes -= err.shift().length; });
|
|
422
|
+
let started = true;
|
|
423
|
+
child.on('error', (e) => { started = false; clearTimeout(timer); finish(e, '', '', before, pend); });
|
|
424
|
+
child.on('close', (code) => { clearTimeout(timer); if (started) finish(null, Buffer.concat(out).toString('utf8'), Buffer.concat(err).toString('utf8'), before, pend, code); });
|
|
425
|
+
})().catch((e) => { log(`[bridgerator] delegate run ${rid} failed: ${e.code || e.message}`); finish(e, '', ''); });
|
|
426
|
+
|
|
427
|
+
function finish(e, stdout, stderr, before = null, pend = null, code = null) {
|
|
428
|
+
if (finished) return;
|
|
429
|
+
finished = true;
|
|
430
|
+
try {
|
|
431
|
+
const token = ctx.cfg?.token;
|
|
432
|
+
const p = e ? { text: '', error: `could not start: ${e.code || e.message}` } : parseOutput(id, stdout);
|
|
433
|
+
const after = before && !before.over ? scanTree(folder) : null;
|
|
434
|
+
const changes = before?.files && after?.files ? diffTrees(before.files, after.files) : null;
|
|
435
|
+
if (!changes && before && !before.over) rec.changedWhy = 'the folder could not be read after the run';
|
|
436
|
+
let kept = null;
|
|
437
|
+
if (pend) { try { kept = snapshots.commit(pend, true, { changedOnly: true, added: changes ? changes.created : [] }); } catch (x) { log(`[bridgerator] delegate snapshot not kept: ${x.code || x.message}`); } }
|
|
438
|
+
rec.state = entry.canceled ? 'cancelled' : entry.timedOut ? 'timeout' : !e && code === 0 && !p.error ? 'done' : 'failed';
|
|
439
|
+
rec.ok = rec.state === 'done';
|
|
440
|
+
if (code !== null) rec.exitCode = code;
|
|
441
|
+
rec.text = cap(mask(p.text || '', token), limitsOf(ctx.cfg).maxOutputKb * 1024);
|
|
442
|
+
const errText = p.error || (rec.state === 'failed' ? tail(stderr, 600) || `exit code ${code}` : rec.state === 'timeout' ? `stopped after ${L.timeoutMin} min (time limit)` : rec.state === 'cancelled' ? 'cancelled' : '');
|
|
443
|
+
if (errText) rec.error = mask(errText, token).slice(0, 1000);
|
|
444
|
+
rec.summary = (rec.ok ? rec.text : rec.error || rec.text).replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
445
|
+
if (changes) rec.changed = changes;
|
|
446
|
+
if (kept) { rec.undoId = kept.id; delete rec.undoWhy; } else if (pend) rec.undoWhy = 'no file changes to undo';
|
|
447
|
+
if (notes.length) rec.notes = notes;
|
|
448
|
+
rec.endedAt = new Date(now()).toISOString();
|
|
449
|
+
} catch (x) {
|
|
450
|
+
rec.state = 'failed'; rec.ok = false; rec.error = `Bridgerator could not finish the run: ${x.code || x.message}`; rec.endedAt = new Date(now()).toISOString();
|
|
451
|
+
}
|
|
452
|
+
live.delete(rid); save(); onChange();
|
|
453
|
+
if (rec.background) emit({ kind: 'delegate_done', ok: rec.ok ? 1 : 0 });
|
|
454
|
+
resolveDone(rec);
|
|
455
|
+
}
|
|
456
|
+
entry.cancel = () => { entry.canceled = true; if (entry.child) kill(entry.child); };
|
|
457
|
+
|
|
458
|
+
let t;
|
|
459
|
+
const r = waitS > 0 ? await Promise.race([entry.done, new Promise((res) => { t = setTimeout(() => res(null), waitS * 1000); t.unref?.(); })]) : null;
|
|
460
|
+
clearTimeout(t);
|
|
461
|
+
if (r) return json(resultView(r));
|
|
462
|
+
rec.background = true; save();
|
|
463
|
+
return json({ id: rid, agent: id, state: 'running', ...(notes.length ? { notes } : {}), note: 'Still running in the background. Check it with action "status" (id) and read the answer with action "result". The person sees it on the Agents page of the Bridgerator window.' });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
detect,
|
|
468
|
+
agents: async (force) => (await detect(force)).map(publicAgent),
|
|
469
|
+
run,
|
|
470
|
+
get: (id) => find(String(id)),
|
|
471
|
+
result: (id) => resultView(find(String(id))),
|
|
472
|
+
status: (id) => (id ? slim(find(String(id))) : { running: runs.filter((r) => r.state === 'running').map(slim), recent: runs.slice(0, 10).map(slim) }),
|
|
473
|
+
async cancel(id) {
|
|
474
|
+
const r = find(String(id));
|
|
475
|
+
const e = live.get(r.id);
|
|
476
|
+
if (!e) fail('NOT_RUNNING', `Run ${r.id} is not running (${r.state}).`);
|
|
477
|
+
e.cancel();
|
|
478
|
+
await Promise.race([e.done, new Promise((res) => setTimeout(res, 10000).unref?.())]);
|
|
479
|
+
return slim(r);
|
|
480
|
+
},
|
|
481
|
+
/** For the window (no process started here: detection runs in the background once). */
|
|
482
|
+
view(cfg) {
|
|
483
|
+
if (!detected && !detecting) detect().catch((e) => log(`[bridgerator] agent detection failed: ${e.message}`));
|
|
484
|
+
const lim = limitsOf(cfg);
|
|
485
|
+
return { checking: !detected, agents: (detected?.list || []).map((a) => ({ ...publicAgent(a), allowed: lim[a.id] })), limits: lim, running: live.size, runs: runs.map(slim) };
|
|
486
|
+
},
|
|
487
|
+
get running() { return live.size; },
|
|
488
|
+
list: () => runs.map(slim),
|
|
489
|
+
stop() { for (const e of live.values()) e.cancel(); },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Bridgerator's own folders + the person's excluded ones: deny rules for agents that have them (Claude Code). */
|
|
494
|
+
export function denyDirsOf(cfg = {}) {
|
|
495
|
+
return [...new Set([...ownDenied(), ...(cfg.protectSensitive === true ? sensitiveDenied() : []), ...(cfg.deniedDirectories || []), ...(cfg.configDir ? [cfg.configDir] : [])].filter((d) => typeof d === 'string' && /^[a-z]:[\\/]/i.test(d)))];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- the tool ------------------------------------------------------------------------------------------------
|
|
499
|
+
const ACTIONS = ['agents', 'run', 'status', 'result', 'cancel'];
|
|
500
|
+
export const HANDOFF = 'The other way round (another AI hands work to ChatGPT): that AI, connected to Bridgerator, adds tasks with queue_add {title, details, from: "<its name>", resultPath: "<file for the result>"}; the person says "Work through my Bridgerator queue" in ChatGPT; ChatGPT writes each result to resultPath (or the note) and marks the task done; the first AI reads queue_list.';
|
|
501
|
+
export const DELEGATE_TOOL = {
|
|
502
|
+
name: 'bridgerator_delegate',
|
|
503
|
+
title: 'Delegate to another AI agent',
|
|
504
|
+
description: 'Hands a task to another AI agent installed on this PC (Codex, Claude Code or Gemini CLI). It works in one folder with the person\'s own subscription and returns its answer and the files it changed. Actions: agents (what is installed and signed in, limits), run (agent, folder, task, from), status, result, cancel (id). Long runs continue in the background: check status, then result. All file changes of a run can be undone with bridgerator_undo. To hand a task to ChatGPT instead, use the task queue (queue_add with from and resultPath).',
|
|
505
|
+
inputSchema: {
|
|
506
|
+
type: 'object', additionalProperties: false, required: ['action'],
|
|
507
|
+
properties: {
|
|
508
|
+
action: { type: 'string', enum: ACTIONS },
|
|
509
|
+
agent: { type: 'string', enum: AGENT_IDS },
|
|
510
|
+
task: { type: 'string', maxLength: MAX_TASK, description: 'What the agent should do, in plain words, with everything it needs to know.' },
|
|
511
|
+
folder: { type: 'string', maxLength: 1000, description: 'Full path of the folder it works in.' },
|
|
512
|
+
from: { type: 'string', maxLength: 80, description: 'Who hands the task over, e.g. "ChatGPT" (shown to the person).' },
|
|
513
|
+
mode: { type: 'string', enum: MODES, description: 'read, edit or full (default: the limit set on the PC, full unless changed).' },
|
|
514
|
+
wait_seconds: { type: 'integer', minimum: 0, maximum: 240, description: 'Wait this long for the answer before the run continues in the background (default 45).' },
|
|
515
|
+
id: { type: 'string', maxLength: 40, description: 'Run id (status, result, cancel).' },
|
|
516
|
+
refresh: { type: 'boolean', description: 'agents: look again (after installing or signing in).' },
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
520
|
+
};
|
|
521
|
+
export const defs = [DELEGATE_TOOL];
|
|
522
|
+
|
|
523
|
+
// One store per bridge (bridge.mjs attaches it; ctx.cfg is the same object for the bridge's lifetime).
|
|
524
|
+
const stores = new WeakMap();
|
|
525
|
+
export const attachDelegate = (cfg, store) => { stores.set(cfg, store); return store; };
|
|
526
|
+
export const delegateFor = (cfg) => stores.get(cfg) || null;
|
|
527
|
+
|
|
528
|
+
export async function handle(name, args, ctx) {
|
|
529
|
+
const d = delegateFor(ctx.cfg);
|
|
530
|
+
const action = needAction(args, ACTIONS);
|
|
531
|
+
if (!d) fail('NOT_AVAILABLE', 'Delegation is not available on this PC.', 'Bridgerator needs to be installed with its settings folder.');
|
|
532
|
+
const L = limitsOf(ctx.cfg);
|
|
533
|
+
switch (action) {
|
|
534
|
+
case 'agents': {
|
|
535
|
+
const list = (await d.agents(args.refresh === true)).map((a) => ({ ...a, allowed: L[a.id], usable: a.found && a.startable && L[a.id] }));
|
|
536
|
+
return json({ agents: list, limits: { mode: L.mode, parallel: L.parallel, timeoutMinutes: L.timeoutMin, maxAnswerKb: L.maxOutputKb, undoCopyMb: L.undoMb }, running: d.running, handoff: HANDOFF });
|
|
537
|
+
}
|
|
538
|
+
case 'run': return d.run(ctx, args);
|
|
539
|
+
case 'status': return json(d.status(args.id === undefined ? undefined : str(args, 'id', { required: true, max: 40 })));
|
|
540
|
+
case 'result': return json(d.result(str(args, 'id', { required: true, max: 40 })));
|
|
541
|
+
case 'cancel': return json({ cancelled: await d.cancel(str(args, 'id', { required: true, max: 40 })) });
|
|
542
|
+
default: return fail('BAD_ACTION', `action must be one of: ${ACTIONS.join(', ')}.`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Window requests (the person at the PC). Undo of a run = the window's undo.run with its undoId. */
|
|
547
|
+
export async function windowRpc(d, method, p = {}) {
|
|
548
|
+
if (!d) throw new Error('delegation is not available');
|
|
549
|
+
switch (method) {
|
|
550
|
+
case 'delegate.detect': return (await d.agents(true));
|
|
551
|
+
case 'delegate.get': { const r = d.get(String(p.id)); return { ...r, files: r.changed ? { modified: capList(r.changed.modified), created: capList(r.changed.created), deleted: capList(r.changed.deleted) } : null }; }
|
|
552
|
+
case 'delegate.cancel': return d.cancel(String(p.id));
|
|
553
|
+
default: throw new Error(`unknown request ${method}`);
|
|
554
|
+
}
|
|
555
|
+
}
|