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/policy.mjs
CHANGED
|
@@ -1,109 +1,293 @@
|
|
|
1
|
-
// Bridge-side access policy, applied BEFORE a call reaches Desktop Commander and to its results.
|
|
2
|
-
// Desktop Commander itself only knows allowedDirectories; it has no "except these folders" rule,
|
|
3
|
-
// so deniedDirectories is enforced here: input paths are resolved (symlinks/junctions included)
|
|
4
|
-
// and refused when they fall inside a denied folder; result lines that mention a denied path are
|
|
5
|
-
// dropped (search hits, recursive listings).
|
|
6
|
-
// The terminal is NOT covered by folder rules (a shell can read anything the user can); commands
|
|
7
|
-
// that name a denied folder are refused as a courtesy, the real control is disabledTools.
|
|
8
|
-
import fs from 'node:fs';
|
|
9
|
-
import os from 'node:os';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
export function
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
1
|
+
// Bridge-side access policy, applied BEFORE a call reaches Desktop Commander and to its results.
|
|
2
|
+
// Desktop Commander itself only knows allowedDirectories; it has no "except these folders" rule,
|
|
3
|
+
// so deniedDirectories is enforced here: input paths are resolved (symlinks/junctions included)
|
|
4
|
+
// and refused when they fall inside a denied folder; result lines that mention a denied path are
|
|
5
|
+
// dropped (search hits, recursive listings).
|
|
6
|
+
// The terminal is NOT covered by folder rules (a shell can read anything the user can); commands
|
|
7
|
+
// that name a denied folder are refused as a courtesy, the real control is disabledTools.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
// Tools the model must never get: they would let it rewrite its own limits, leak telemetry, read the
|
|
14
|
+
// local log of earlier calls, or fetch from the network (PDF export loads remote content).
|
|
15
|
+
export const ALWAYS_HIDDEN = ['set_config_value', 'give_feedback_to_desktop_commander', 'get_usage_stats', 'write_pdf', 'get_recent_tool_calls'];
|
|
16
|
+
// Tools that change files; refused inside readOnlyDirectories (move_file: on either side).
|
|
17
|
+
export const WRITE_TOOLS = ['write_file', 'edit_block', 'move_file', 'create_directory'];
|
|
18
|
+
// Argument names that carry filesystem paths (at any depth): path, paths, file_path, outputPath, directory, source, destination…
|
|
19
|
+
const MAX_DEPTH = 8, MAX_NODES = 5000;
|
|
20
|
+
const PATH_KEYS = /(path|paths|file|files|dir|directory|folder|source|dest|destination)$/i;
|
|
21
|
+
|
|
22
|
+
// The terminal: off in the access presets Read only / Files (config disabledTools). The bridge always
|
|
23
|
+
// lists these, refused while off, so turning the terminal on needs no restart.
|
|
24
|
+
export const TERMINAL_TOOLS = ['start_process', 'interact_with_process', 'kill_process'];
|
|
25
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url)).replace(/[\\/]+$/, '');
|
|
26
|
+
|
|
27
|
+
// LOGIC: see critical-logic/bridge.md. Always excluded, whatever the config says: Bridgerator's own
|
|
28
|
+
// settings, key, snapshots and program files — without this the AI could switch any protection off.
|
|
29
|
+
export function ownDenied(env = process.env, home = os.homedir()) {
|
|
30
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
31
|
+
const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
32
|
+
const npm = path.join(appData, 'npm');
|
|
33
|
+
return [path.join(appData, 'Bridgerator'), path.join(local, 'Bridgerator'), path.join(npm, 'node_modules', 'bridgerator'),
|
|
34
|
+
...['bridgerator', 'bridgerator.cmd', 'bridgerator.ps1'].map((x) => path.join(npm, x)), PKG];
|
|
35
|
+
}
|
|
36
|
+
// "Protect sensitive folders" (config protectSensitive, off by default since 21.10): autostart folder,
|
|
37
|
+
// shell profiles that run code at start, credentials, the whole AppData.
|
|
38
|
+
export function sensitiveDenied(env = process.env, home = os.homedir()) {
|
|
39
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
40
|
+
const docs = [path.join(home, 'Documents'), ...(env.OneDrive ? [path.join(env.OneDrive, 'Documents')] : [])];
|
|
41
|
+
return [
|
|
42
|
+
path.join(appData, 'npm'), path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'),
|
|
43
|
+
...docs.flatMap((d) => [path.join(d, 'WindowsPowerShell'), path.join(d, 'PowerShell')]),
|
|
44
|
+
...['.ssh', '.aws', '.azure', '.kube', '.docker', '.gnupg', '.config', '.npmrc', '.git-credentials', '.gitconfig', '.claude', 'AppData'].map((x) => path.join(home, x)),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
/** Built-in exclusions: own folders always; sensitive ones unless protectSensitive is false. */
|
|
48
|
+
export function builtinDenied(env = process.env, home = os.homedir(), { protectSensitive = true } = {}) {
|
|
49
|
+
return [...ownDenied(env, home), ...(protectSensitive ? sensitiveDenied(env, home) : [])];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const expandHome = (p) => (p === '~' || p.startsWith('~/') || p.startsWith('~\\') ? path.join(os.homedir(), p.slice(1)) : p);
|
|
53
|
+
// base: the folder a relative path is resolved against (default: this process's cwd).
|
|
54
|
+
export const norm = (p, base) => path.resolve(base || '.', expandHome(String(p))).replace(/[\\/]+$/, '').toLowerCase();
|
|
55
|
+
|
|
56
|
+
// Resolve symlinks/junctions on the deepest existing ancestor, like Desktop Commander does.
|
|
57
|
+
export function realish(p, base) {
|
|
58
|
+
const start = path.resolve(base || '.', expandHome(String(p)));
|
|
59
|
+
let cur = start;
|
|
60
|
+
const rest = [];
|
|
61
|
+
for (;;) {
|
|
62
|
+
try { return path.join(fs.realpathSync.native(cur), ...rest); } catch { /* walk up */ }
|
|
63
|
+
const up = path.dirname(cur);
|
|
64
|
+
if (up === cur) return start;
|
|
65
|
+
rest.unshift(path.basename(cur));
|
|
66
|
+
cur = up;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
// "c:\secret" mentioned as a path (not as the prefix of "c:\secretive"), any slash style / JSON escaping.
|
|
72
|
+
const flat = (s) => String(s).toLowerCase().replace(/[\\/]+/g, '\\');
|
|
73
|
+
const mentionRe = (d) => new RegExp(`${esc(d)}(?![a-z0-9_.\\-])`);
|
|
74
|
+
|
|
75
|
+
// Quotes and the escape characters of cmd (^) and PowerShell (`) can split a path in a command.
|
|
76
|
+
export const unquote = (s) => String(s).replace(/["'^`]/g, '');
|
|
77
|
+
// 8.3 short names (C:\Users\3C8A~1\…): the deepest existing part is resolved to its long name.
|
|
78
|
+
// Linear: tokens first (one regex over the whole command backtracks quadratically), at most 50 lookups.
|
|
79
|
+
export const longNames = (s) => {
|
|
80
|
+
let n = 0;
|
|
81
|
+
return String(s).replace(/[^\s"'|&;<>]+/g, (tok) => (n < 50 && /~\d/.test(tok) ? tok.replace(/[a-z]:[\\/].*$/i, (m) => (/~\d/.test(m) && ++n <= 50 ? realish(m) : m)) : tok));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Path-like parts of a command: absolute (C:\…, also after = or >) and relative with a backslash
|
|
85
|
+
// (sub\x, .\x, ..\x), quoted or not. Capped (count and length): this runs on every command.
|
|
86
|
+
const MAX_CMD_PATHS = 64;
|
|
87
|
+
export function commandPaths(s) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const m of String(s).slice(0, 20000).matchAll(/"([^"]{1,1000})"|'([^']{1,1000})'|([^\s"'|&;<>(),]{1,1000})/g)) {
|
|
90
|
+
let t = (m[1] ?? m[2] ?? m[3]).trim();
|
|
91
|
+
const d = /[a-z]:[\\/]/i.exec(t);
|
|
92
|
+
if (d) t = t.slice(d.index);
|
|
93
|
+
else if (/^[-/]/.test(t) || !t.includes('\\') || t.includes(':')) continue; // a flag, a switch, no folder part, a URL
|
|
94
|
+
out.push(t);
|
|
95
|
+
if (out.length >= MAX_CMD_PATHS) break;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
// What a command WRITES (a heuristic like the other command checks — the terminal is not a boundary):
|
|
100
|
+
// redirect targets (> / >> / 2> file; not 2>&1, >nul, >$null), the destination of a copy (-Destination
|
|
101
|
+
// or the last path), every path after a move / delete / write verb of its own segment (a move empties
|
|
102
|
+
// its source too). Read sources and paths of other segments (a | b ; c && d) are not targets.
|
|
103
|
+
const COPY_VERB = /^(copy|xcopy|robocopy|cp|cpi|copy-item)$/i;
|
|
104
|
+
const WRITE_VERB = /^(set-content|add-content|out-file|new-item|ni|remove-item|ri|rm|del|erase|rd|rmdir|mkdir|md|move|mv|mi|move-item|copy|xcopy|robocopy|cp|cpi|copy-item|ren|rename|rename-item|rni|clear-content|tee-object|tee|expand-archive|compress-archive|icacls|attrib|takeown)(\.exe)?$/i;
|
|
105
|
+
const NUL_TARGET = /^(\$?null|nul|con)$/i;
|
|
106
|
+
const segments = (s) => {
|
|
107
|
+
const out = [];
|
|
108
|
+
let cur = '', q = '';
|
|
109
|
+
for (const ch of String(s).slice(0, 20000)) {
|
|
110
|
+
if (q) { if (ch === q) q = ''; cur += ch; continue; }
|
|
111
|
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
|
112
|
+
if (ch === ';' || ch === '|' || ch === '&' || ch === '\n' || ch === '\r') { out.push(cur); cur = ''; continue; }
|
|
113
|
+
cur += ch;
|
|
114
|
+
}
|
|
115
|
+
out.push(cur);
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
export function writeTargets(s) {
|
|
119
|
+
const out = [];
|
|
120
|
+
const text = String(s).slice(0, 20000);
|
|
121
|
+
for (const m of text.matchAll(/[*\d]?>>?(?!&)\s*("([^"]{1,1000})"|'([^']{1,1000})'|([^\s"'|&;<>()]{1,1000}))/g)) {
|
|
122
|
+
const t = (m[2] ?? m[3] ?? m[4]).trim();
|
|
123
|
+
if (t && !NUL_TARGET.test(t)) out.push(t);
|
|
124
|
+
}
|
|
125
|
+
for (const seg of segments(text)) {
|
|
126
|
+
const words = [...seg.matchAll(/[^\s"'(){}=]+/g)];
|
|
127
|
+
const v = words.find((w) => WRITE_VERB.test(w[0].replace(/^[.&@]+/, '')));
|
|
128
|
+
if (!v) continue;
|
|
129
|
+
const rest = seg.slice(v.index + v[0].length).replace(/[*\d]?>>?(?!&)\s*("[^"]*"|'[^']*'|[^\s"'|&;<>()]+)/g, ' ');
|
|
130
|
+
const named = commandPaths(rest);
|
|
131
|
+
if (COPY_VERB.test(v[0].replace(/^[.&@]+|\.exe$/gi, ''))) {
|
|
132
|
+
const d = /(^|\s)-destination\s+("[^"]{1,1000}"|'[^']{1,1000}'|[^\s"']{1,1000})/i.exec(rest);
|
|
133
|
+
const dest = d ? commandPaths(d[2])[0] : named.at(-1);
|
|
134
|
+
if (dest) out.push(dest);
|
|
135
|
+
} else out.push(...named);
|
|
136
|
+
if (out.length >= MAX_CMD_PATHS) break;
|
|
137
|
+
}
|
|
138
|
+
return out.slice(0, MAX_CMD_PATHS);
|
|
139
|
+
}
|
|
140
|
+
/** %VAR%, $env:VAR, ${env:VAR} and a leading ~ expanded with the real environment (case-insensitive names). */
|
|
141
|
+
export function expandVars(s, env = process.env, home = os.homedir()) {
|
|
142
|
+
const get = (n) => { const k = Object.keys(env).find((x) => x.toLowerCase() === String(n).toLowerCase()); return k ? env[k] : undefined; };
|
|
143
|
+
return String(s)
|
|
144
|
+
.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => get(n) ?? m)
|
|
145
|
+
.replace(/\$\{env:([^}]+)\}/gi, (m, n) => get(n) ?? m)
|
|
146
|
+
.replace(/\$env:([A-Za-z_]\w*)/gi, (m, n) => get(n) ?? m)
|
|
147
|
+
.replace(/(^|[\s"'(=;,])~(?=[\\/]|$|[\s"'])/g, (m, pre) => pre + home);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// cfg.cwd: the folder the ENGINE resolves relative paths against (its working directory): relative
|
|
151
|
+
// arguments are checked where the engine will open them, never against the bridge's own folder.
|
|
152
|
+
export function createPolicy(cfg = {}) {
|
|
153
|
+
const base = cfg.cwd || process.cwd();
|
|
154
|
+
// protectSensitive absent = on (callers that don't say); the bridge passes the person's setting.
|
|
155
|
+
const denied = [...new Set([...builtinDenied(process.env, os.homedir(), { protectSensitive: cfg.protectSensitive !== false }), ...(cfg.deniedDirectories || [])].map((d) => norm(realish(d))))];
|
|
156
|
+
const res = denied.map(mentionRe);
|
|
157
|
+
const mentionsDenied = (s) => { const f = flat(s); return res.some((r) => r.test(f)); };
|
|
158
|
+
const hidden = new Set([...ALWAYS_HIDDEN, ...(cfg.disabledTools || [])]);
|
|
159
|
+
const inDenied = (p) => {
|
|
160
|
+
const n = norm(p, base);
|
|
161
|
+
return denied.find((d) => n === d || n.startsWith(d + path.sep)) || null;
|
|
162
|
+
};
|
|
163
|
+
const deniedFor = (p) => inDenied(p) || inDenied(realish(p, base));
|
|
164
|
+
const readOnly = (cfg.readOnlyDirectories || []).map((d) => norm(realish(d)));
|
|
165
|
+
const inRo = (n) => readOnly.some((d) => n === d || n.startsWith(d + path.sep));
|
|
166
|
+
const readOnlyFor = (p) => inRo(norm(p, base)) || inRo(norm(realish(p, base)));
|
|
167
|
+
// A folder that CONTAINS an excluded / read-only folder (moving or deleting it takes that folder along).
|
|
168
|
+
const holds = (list, p) => { const a = norm(p, base), b = norm(realish(p, base)); return list.some((d) => d.startsWith(a + path.sep) || d.startsWith(b + path.sep)); };
|
|
169
|
+
|
|
170
|
+
// Every path-like string at any depth — iterative, with caps (deep / huge arguments are refused).
|
|
171
|
+
function pathsIn(args) {
|
|
172
|
+
const out = [], stack = [[args, '', 0]];
|
|
173
|
+
let seen = 0;
|
|
174
|
+
while (stack.length) {
|
|
175
|
+
const [v, key, depth] = stack.pop();
|
|
176
|
+
if (++seen > MAX_NODES || depth > MAX_DEPTH) throw new Error('arguments too deeply nested or too large');
|
|
177
|
+
if (typeof v === 'string') { if (v && PATH_KEYS.test(key)) out.push(v); }
|
|
178
|
+
else if (Array.isArray(v)) for (const x of v) stack.push([x, key, depth + 1]);
|
|
179
|
+
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) stack.push([x, k, depth + 1]);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
denied,
|
|
186
|
+
visible: (tool) => !hidden.has(tool.name),
|
|
187
|
+
// → null when allowed, otherwise the refusal text returned to the model.
|
|
188
|
+
check(name, args) {
|
|
189
|
+
if (hidden.has(name)) return TERMINAL_TOOLS.includes(name) ? 'The terminal is off on this PC (access preset Read only or Files). The person can switch to Full in Bridgerator → Safety, or you can ask with bridgerator_settings.' : `Tool "${name}" is disabled on this computer.`;
|
|
190
|
+
if (name === 'read_file' && args?.isUrl) return 'Reading URLs is disabled on this computer.';
|
|
191
|
+
let paths;
|
|
192
|
+
try { paths = pathsIn(args); } catch (e) { return `Refused by Bridgerator: ${e.message}.`; }
|
|
193
|
+
for (const p of paths) {
|
|
194
|
+
// UNC / device paths (\\server\share, \\?\, \\.\): network shares and raw devices are out of scope.
|
|
195
|
+
if (/^[\\/]{2}/.test(p.trim())) return `Access denied by Bridgerator: network and device paths ("${p}") are not allowed.`;
|
|
196
|
+
if (deniedFor(p)) return `Access denied by Bridgerator: "${p}" is inside an excluded folder.`;
|
|
197
|
+
if (WRITE_TOOLS.includes(name) && readOnlyFor(p)) return `Access denied by Bridgerator: "${p}" is in a read-only folder — files there can be read, not changed.`;
|
|
198
|
+
if (WRITE_TOOLS.includes(name) && holds(denied, p)) return `Access denied by Bridgerator: "${p}" contains an excluded folder.`;
|
|
199
|
+
if (WRITE_TOOLS.includes(name) && holds(readOnly, p)) return `Access denied by Bridgerator: "${p}" contains a read-only folder.`;
|
|
200
|
+
// A hard link is the same file under another name (maybe inside an excluded folder): no writes.
|
|
201
|
+
if (WRITE_TOOLS.includes(name)) { try { if (fs.lstatSync(path.resolve(base, expandHome(p))).nlink > 1) return `Access denied by Bridgerator: "${p}" is a hard-linked file — it can't be changed through Bridgerator.`; } catch { /* doesn't exist yet */ } }
|
|
202
|
+
}
|
|
203
|
+
for (const k of ['command', 'input']) {
|
|
204
|
+
if (typeof args?.[k] !== 'string') continue;
|
|
205
|
+
// Checked as written AND with variables expanded (%APPDATA%, $env:APPDATA, ${env:APPDATA}, ~),
|
|
206
|
+
// with quotes / escape characters removed ("%USERPROFILE%"\.ssh, .s^sh, .`ssh) and 8.3 short
|
|
207
|
+
// names of existing folders made long (C:\PROGRA~1).
|
|
208
|
+
const u = unquote(args[k]);
|
|
209
|
+
const texts = [args[k], expandVars(args[k]), u, expandVars(u)];
|
|
210
|
+
texts.push(...texts.map(longNames));
|
|
211
|
+
if (texts.some(mentionsDenied)) return `Access denied by Bridgerator: the ${k} refers to an excluded folder.`;
|
|
212
|
+
// Paths in the command resolved like the file tools do (relative → the terminal's folder,
|
|
213
|
+
// junctions / symbolic links → their target): a link to an excluded folder is refused too.
|
|
214
|
+
const named = [...new Set([...commandPaths(texts[1]), ...commandPaths(texts[3])])].slice(0, MAX_CMD_PATHS);
|
|
215
|
+
// UNC / device paths are refused like path arguments — never resolved here (a lookup on a
|
|
216
|
+
// network path blocks the bridge's main thread).
|
|
217
|
+
const unc = named.find((p) => /^[\\/]{2}[^\\/]+[\\/]/.test(p));
|
|
218
|
+
if (unc) return `Access denied by Bridgerator: network and device paths ("${unc}") are not allowed.`;
|
|
219
|
+
if (named.some((p) => deniedFor(p))) return `Access denied by Bridgerator: the ${k} refers to an excluded folder.`;
|
|
220
|
+
// Read-only folders, for commands that change files (a courtesy: a script can still write there).
|
|
221
|
+
// Only what the command writes counts (as written with its quotes and as unquoted).
|
|
222
|
+
if (readOnly.length) {
|
|
223
|
+
const targets = [...new Set([...writeTargets(texts[1]), ...writeTargets(texts[3])])].filter((p) => !/^[\\/]{2}/.test(p));
|
|
224
|
+
if (targets.some((p) => readOnlyFor(p))) return `Access denied by Bridgerator: the ${k} would change a read-only folder — files there can be read, not changed.`;
|
|
225
|
+
}
|
|
226
|
+
// Courtesy, like the folder check (the terminal itself is not a boundary): anything about
|
|
227
|
+
// Bridgerator itself — its commands, files, processes — is for the person at the PC.
|
|
228
|
+
if (texts.some((x) => /\bbridgerator\b/i.test(x))) return 'Commands about Bridgerator itself (its settings, files or processes) can only be run by the person at this PC. Use request_confirm_off to ask for confirmations to be turned off.';
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
},
|
|
232
|
+
/** Refusal text when Bridgerator itself may not remove / restore this path (excluded, read-only, network), else null. */
|
|
233
|
+
writable(p) {
|
|
234
|
+
if (/^[\\/]{2}/.test(String(p).trim())) return `Access denied by Bridgerator: network and device paths ("${p}") are not allowed.`;
|
|
235
|
+
if (deniedFor(p)) return `Access denied by Bridgerator: "${p}" is inside an excluded folder.`;
|
|
236
|
+
if (readOnlyFor(p)) return `Access denied by Bridgerator: "${p}" is in a read-only folder.`;
|
|
237
|
+
if (holds(denied, p)) return `Access denied by Bridgerator: "${p}" contains an excluded folder.`;
|
|
238
|
+
if (holds(readOnly, p)) return `Access denied by Bridgerator: "${p}" contains a read-only folder.`;
|
|
239
|
+
return null;
|
|
240
|
+
},
|
|
241
|
+
// Drop result lines that reveal anything inside a denied folder. `args` lets a listing of a
|
|
242
|
+
// parent folder hide the excluded child, whose entries are printed relative to that parent.
|
|
243
|
+
filter(result, name, args) {
|
|
244
|
+
if (!denied.length || !result || !Array.isArray(result.content)) return result;
|
|
245
|
+
const rel = [];
|
|
246
|
+
if (name === 'list_directory' && typeof args?.path === 'string') {
|
|
247
|
+
const at = norm(realish(args.path, base));
|
|
248
|
+
for (const d of denied) if (d.startsWith(at + path.sep)) rel.push(new RegExp(`^${esc(d.slice(at.length + 1))}(?![a-z0-9_.\\-])`));
|
|
249
|
+
}
|
|
250
|
+
const leaks = (line) => mentionsDenied(line) || rel.some((r) => r.test(flat(line).trim().replace(/^\[(dir|file)\]\s*/, '')));
|
|
251
|
+
let dropped = 0;
|
|
252
|
+
const content = result.content.map((c) => {
|
|
253
|
+
if (c?.type !== 'text' || typeof c.text !== 'string') return c;
|
|
254
|
+
const lines = c.text.split(/\r?\n/);
|
|
255
|
+
const kept = lines.filter((ln) => !leaks(ln));
|
|
256
|
+
dropped += lines.length - kept.length;
|
|
257
|
+
return { ...c, text: kept.join('\n') };
|
|
258
|
+
});
|
|
259
|
+
if (dropped) content.push({ type: 'text', text: `[Bridgerator: ${dropped} line(s) hidden — excluded folder]` });
|
|
260
|
+
return { ...result, content };
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The bundled engine's own name and links are not shown to the user: tool titles/descriptions and
|
|
266
|
+
// widget HTML say Bridgerator. Tool RESULTS are never rewritten (they are the user's own data).
|
|
267
|
+
const BRAND = [[/"DC: \.\.\." or /g, ''], [/Desktop ?Commander/g, 'Bridgerator'], [/https?:\/\/(www\.)?desktopcommander\.app[^\s"'\)<]*/g, 'https://bridgerator.recondeck.com']];
|
|
268
|
+
export function debrand(v) {
|
|
269
|
+
if (typeof v === 'string') return BRAND.reduce((s, [re, to]) => s.replace(re, to), v);
|
|
270
|
+
if (Array.isArray(v)) return v.map(debrand);
|
|
271
|
+
if (v && typeof v === 'object') {
|
|
272
|
+
const o = {};
|
|
273
|
+
// _meta keys (ui:// template URIs) are identifiers, not user-facing text — keep them as is.
|
|
274
|
+
for (const [k, x] of Object.entries(v)) o[k] = k === '_meta' || k === 'uri' ? x : debrand(x);
|
|
275
|
+
return o;
|
|
276
|
+
}
|
|
277
|
+
return v;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Two results are the engine's own text, not user data: the prompt catalog (get_prompts says
|
|
281
|
+
// "Desktop Commander Prompt Categories") and get_config, whose `version` is the engine's and is
|
|
282
|
+
// shown as "Bridgerator version" — replaced by the bridge's own version.
|
|
283
|
+
export function debrandResult(name, result, version) {
|
|
284
|
+
if (!result || typeof result !== 'object') return result;
|
|
285
|
+
if (name === 'get_prompts') return debrand(result);
|
|
286
|
+
const sc = result.structuredContent, engine = sc?.config?.version;
|
|
287
|
+
if (name !== 'get_config' || typeof engine !== 'string') return result;
|
|
288
|
+
const label = `"version": ${JSON.stringify(engine)}`;
|
|
289
|
+
const content = Array.isArray(result.content)
|
|
290
|
+
? result.content.map((c) => (c?.type === 'text' && typeof c.text === 'string' ? { ...c, text: c.text.replace(label, `"version": ${JSON.stringify(version)}`) } : c))
|
|
291
|
+
: result.content;
|
|
292
|
+
return { ...result, content, structuredContent: { ...sc, config: { ...sc.config, version } } };
|
|
293
|
+
}
|
package/src/queue.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Task queue (checklist) on this PC: the person adds steps in the Bridgerator window, ChatGPT works
|
|
2
|
+
// through them with the queue_* tools. Stored only here (%APPDATA%\Bridgerator\queue.json, inside
|
|
3
|
+
// the always-excluded folder); never sent to the server. Tools read/write only this list.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { writeAtomic } from './confirm.mjs';
|
|
7
|
+
|
|
8
|
+
export const STATUSES = ['todo', 'doing', 'done', 'failed', 'skipped'];
|
|
9
|
+
const MAX_ITEMS = 500, MAX_TITLE = 200, MAX_DETAILS = 4000, MAX_NOTE = 1000, MAX_PATHS = 20;
|
|
10
|
+
const RULE = 'The usual loop: queue_next, do the task, queue_update with done / failed / skipped and a short note, then the next one until the queue is empty or the person stops. resultPath set: write the result to that file. from set: another AI added it and reads queue_list.';
|
|
11
|
+
const MAX_FROM = 80, MAX_RESULT_PATH = 1000;
|
|
12
|
+
export const STALE_MS = 30 * 60 * 1000; // "doing" with no update for this long: another chat gave up
|
|
13
|
+
const ID = { type: 'string', description: 'item id' };
|
|
14
|
+
const STR = (n) => ({ type: 'string', maxLength: n });
|
|
15
|
+
const PATHS = { type: 'array', maxItems: MAX_PATHS, items: STR(1000) };
|
|
16
|
+
// AI ↔ AI hand-off (another AI adds tasks for ChatGPT): who added it, and the file for the result. Optional; old items have neither.
|
|
17
|
+
const HANDOFF = { from: { type: 'string', maxLength: MAX_FROM, description: 'Who adds the task when another AI hands work over, e.g. "Claude Code".' }, resultPath: { type: 'string', maxLength: MAX_RESULT_PATH, description: 'Full path of the file where the result should be written.' } };
|
|
18
|
+
|
|
19
|
+
export const QUEUE_TOOL_DEFS = [
|
|
20
|
+
{ name: 'queue_list', description: `List the task queue the person keeps on this PC (a checklist of things to do): counts and items (id, title, status, note, worker, updatedAt, from, resultPath), optionally only one status.`, inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: STATUSES } } } },
|
|
21
|
+
{ name: 'queue_next', description: `Take the next item of the task queue: returns the first "todo" item and marks it "doing" (nothing when the queue is empty). An item left "doing" for 30 minutes without an update (another chat stopped) is returned first, with resumed: true. Several chats can work through one queue at once; each call gets a different item. Pass worker = a short name for this chat (e.g. its title) so the person sees who is on it. ${RULE}`, inputSchema: { type: 'object', additionalProperties: false, properties: { worker: STR(80) } } },
|
|
22
|
+
{ name: 'queue_update', description: `Set an item's status (doing / done / failed / skipped / todo) with a short result note.`, inputSchema: { type: 'object', additionalProperties: false, required: ['id', 'status'], properties: { id: ID, status: { type: 'string', enum: STATUSES }, note: STR(MAX_NOTE) } } },
|
|
23
|
+
{ name: 'queue_add', description: `Add steps to the task queue (e.g. when planning a bigger job with the person): one { title, details?, paths?, from?, resultPath? } or items: [...] (1–100). Another AI handing work to ChatGPT sets from (its name) and resultPath (a file for the result), then reads the outcome with queue_list.`, inputSchema: { type: 'object', additionalProperties: false, properties: { title: STR(MAX_TITLE), details: STR(MAX_DETAILS), paths: PATHS, ...HANDOFF, items: { type: 'array', minItems: 1, maxItems: 100, items: { type: 'object', additionalProperties: false, required: ['title'], properties: { title: STR(MAX_TITLE), details: STR(MAX_DETAILS), paths: PATHS, ...HANDOFF } } } } } },
|
|
24
|
+
{ name: 'queue_clear', description: 'Remove finished items from the task queue: status done, failed or skipped. status "all" removes every item and needs the person\'s OK on the PC.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['done', 'failed', 'skipped', 'all'] } } } },
|
|
25
|
+
{ name: 'queue_edit', description: 'Change the title, details, paths or result file of a queue item.', inputSchema: { type: 'object', additionalProperties: false, required: ['id'], properties: { id: ID, title: STR(MAX_TITLE), details: STR(MAX_DETAILS), paths: PATHS, resultPath: HANDOFF.resultPath } } },
|
|
26
|
+
{ name: 'queue_reorder', description: 'Put queue items in a new order: ids first, in this order; the rest keep their order after them.', inputSchema: { type: 'object', additionalProperties: false, required: ['ids'], properties: { ids: { type: 'array', maxItems: MAX_ITEMS, items: ID } } } },
|
|
27
|
+
];
|
|
28
|
+
export const QUEUE_TOOL_NAMES = new Set(QUEUE_TOOL_DEFS.map((t) => t.name));
|
|
29
|
+
|
|
30
|
+
const str = (v, n, name, required) => {
|
|
31
|
+
if (v === undefined || v === null) { if (required) throw new Error(`${name} is required`); return undefined; }
|
|
32
|
+
if (typeof v !== 'string') throw new Error(`${name} must be text`);
|
|
33
|
+
const t = v.trim();
|
|
34
|
+
if (required && !t) throw new Error(`${name} is required`);
|
|
35
|
+
if (t.length > n) throw new Error(`${name} is longer than ${n} characters`);
|
|
36
|
+
return t;
|
|
37
|
+
};
|
|
38
|
+
const paths = (v) => {
|
|
39
|
+
if (v === undefined) return undefined;
|
|
40
|
+
if (!Array.isArray(v) || v.length > MAX_PATHS || v.some((p) => typeof p !== 'string' || p.length > 1000)) throw new Error(`paths must be a list of at most ${MAX_PATHS} paths`);
|
|
41
|
+
return v.map((p) => p.trim()).filter(Boolean);
|
|
42
|
+
};
|
|
43
|
+
const fromOf = (v) => { const t = str(v, MAX_FROM, 'from'); if (t && /[\u0000-\u001f]/.test(t)) throw new Error('from must be a short name on one line'); return t || undefined; };
|
|
44
|
+
const resultPathOf = (v) => { const t = str(v, MAX_RESULT_PATH, 'resultPath'); if (t && !/^[a-z]:[\\/]/i.test(t)) throw new Error('resultPath must be a full path like C:\\Users\\Name\\Documents\\result.md'); return t || undefined; };
|
|
45
|
+
const handoff = (x) => { const f = fromOf(x?.from), r = resultPathOf(x?.resultPath); return { ...(f ? { from: f } : {}), ...(r ? { resultPath: r } : {}) }; };
|
|
46
|
+
|
|
47
|
+
export function createQueue(file, { now = () => new Date().toISOString(), onChange = () => {} } = {}) {
|
|
48
|
+
let items = [], unreadable = null;
|
|
49
|
+
// Missing file = empty queue. A file that can't be READ (locked, no access) is never overwritten: saves
|
|
50
|
+
// fail until a restart reads it. A file that can't be PARSED — or has the wrong shape, or items that
|
|
51
|
+
// are dropped (no id / title, unknown status) — is kept as queue.json.broken-<content hash> before any
|
|
52
|
+
// save: once per distinct broken content (the same file at every start is not copied again).
|
|
53
|
+
let raw = null;
|
|
54
|
+
const keepCopy = () => {
|
|
55
|
+
const copy = `${file}.broken-${crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16)}`;
|
|
56
|
+
try { fs.copyFileSync(file, copy, fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'EEXIST') unreadable = 'BROKEN'; }
|
|
57
|
+
};
|
|
58
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') unreadable = e.code || e.message; }
|
|
59
|
+
if (raw !== null) {
|
|
60
|
+
try {
|
|
61
|
+
const j = JSON.parse(raw.replace(/^/, ''));
|
|
62
|
+
if (Array.isArray(j?.items)) {
|
|
63
|
+
items = j.items.filter((x) => x && typeof x === 'object' && typeof x.id === 'string' && x.id && typeof x.title === 'string' && STATUSES.includes(x.status))
|
|
64
|
+
.map((x, i) => ({ ...x, order: Number.isFinite(x.order) ? x.order : i + 1 }));
|
|
65
|
+
if (items.length !== j.items.length) keepCopy();
|
|
66
|
+
} else keepCopy();
|
|
67
|
+
} catch { keepCopy(); }
|
|
68
|
+
}
|
|
69
|
+
// Atomic: write a temp file, then rename over queue.json — a crash or a parallel write never leaves
|
|
70
|
+
// half a file. (Calls run in one process, so the read-modify-write steps never interleave.)
|
|
71
|
+
const save = () => {
|
|
72
|
+
if (unreadable) throw new Error(`queue.json could not be read (${unreadable}); restart Bridgerator to load it — nothing was changed`);
|
|
73
|
+
if (deferSave) return;
|
|
74
|
+
writeAtomic(file, JSON.stringify({ items }, null, 2)); onChange();
|
|
75
|
+
};
|
|
76
|
+
// Every change is all or nothing: a validation or save error leaves the queue as it was.
|
|
77
|
+
const commit = (fn) => {
|
|
78
|
+
const before = structuredClone(items);
|
|
79
|
+
try { return fn(); } catch (e) { items = before; throw e; }
|
|
80
|
+
};
|
|
81
|
+
// Several items in one change: ONE save at the end (a failure half-way leaves queue.json as it was).
|
|
82
|
+
let deferSave = false;
|
|
83
|
+
const many = (fn) => commit(() => { deferSave = true; let r; try { r = fn(); } finally { deferSave = false; } save(); return r; });
|
|
84
|
+
const sorted = () => [...items].sort((a, b) => a.order - b.order);
|
|
85
|
+
const get = (id) => { const it = items.find((x) => x.id === id); if (!it) throw new Error(`no queue item ${id}`); return it; };
|
|
86
|
+
const renumber = (list) => list.forEach((x, i) => { x.order = i + 1; });
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
list: (status) => {
|
|
90
|
+
if (status !== undefined && !STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}`);
|
|
91
|
+
return sorted().filter((x) => !status || x.status === status);
|
|
92
|
+
},
|
|
93
|
+
counts: () => Object.fromEntries(STATUSES.map((s) => [s, items.filter((x) => x.status === s).length])),
|
|
94
|
+
add(x = {}) { return commit(() => this.add0(x)); },
|
|
95
|
+
add0({ title, details, paths: p, from, resultPath } = {}) {
|
|
96
|
+
if (items.length >= MAX_ITEMS) throw new Error(`the queue is full (${MAX_ITEMS} items)`);
|
|
97
|
+
const t = now();
|
|
98
|
+
const it = { id: crypto.randomUUID().slice(0, 8), title: str(title, MAX_TITLE, 'title', true), ...(details !== undefined ? { details: str(details, MAX_DETAILS, 'details') } : {}), ...(p !== undefined ? { paths: paths(p) } : {}), ...handoff({ from, resultPath }), status: 'todo', order: (Math.max(0, ...items.map((x) => x.order)) + 1), createdAt: t, updatedAt: t };
|
|
99
|
+
items.push(it); save(); return it;
|
|
100
|
+
},
|
|
101
|
+
// Synchronous take: parallel chats calling queue_next never get the same item.
|
|
102
|
+
next({ worker } = {}) { return commit(() => {
|
|
103
|
+
const w = str(worker, 80, 'worker');
|
|
104
|
+
const t = Date.parse(now());
|
|
105
|
+
const stale = sorted().find((x) => x.status === 'doing' && t - Date.parse(x.updatedAt) >= STALE_MS);
|
|
106
|
+
const it = stale || sorted().find((x) => x.status === 'todo');
|
|
107
|
+
if (!it) return null;
|
|
108
|
+
if (stale) it.resumedFrom = it.worker || 'another chat';
|
|
109
|
+
it.status = 'doing'; it.updatedAt = now();
|
|
110
|
+
if (w) it.worker = w; else delete it.worker;
|
|
111
|
+
save(); return stale ? { ...it, resumed: true } : it;
|
|
112
|
+
}); },
|
|
113
|
+
update({ id, status, note } = {}) { return commit(() => {
|
|
114
|
+
const it = get(str(id, 40, 'id', true));
|
|
115
|
+
if (!STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}`);
|
|
116
|
+
const n = note !== undefined ? str(note, MAX_NOTE, 'note') : undefined;
|
|
117
|
+
it.status = status; it.updatedAt = now();
|
|
118
|
+
if (note !== undefined) it.note = n;
|
|
119
|
+
if (['done', 'failed', 'skipped'].includes(status)) it.doneAt = it.updatedAt; else delete it.doneAt;
|
|
120
|
+
if (status !== 'doing') delete it.worker;
|
|
121
|
+
save(); return it;
|
|
122
|
+
}); },
|
|
123
|
+
edit({ id, title, details, paths: p, resultPath } = {}) { return commit(() => {
|
|
124
|
+
const it = get(str(id, 40, 'id', true));
|
|
125
|
+
// Everything is checked first, then changed.
|
|
126
|
+
const t = title !== undefined ? str(title, MAX_TITLE, 'title', true) : undefined;
|
|
127
|
+
const d = details !== undefined ? str(details, MAX_DETAILS, 'details') : undefined;
|
|
128
|
+
const ps = p !== undefined ? paths(p) : undefined;
|
|
129
|
+
const r = resultPath !== undefined ? resultPathOf(resultPath) : undefined;
|
|
130
|
+
if (title !== undefined) it.title = t;
|
|
131
|
+
if (details !== undefined) it.details = d;
|
|
132
|
+
if (p !== undefined) it.paths = ps;
|
|
133
|
+
if (resultPath !== undefined) { if (r) it.resultPath = r; else delete it.resultPath; }
|
|
134
|
+
it.updatedAt = now(); save(); return it;
|
|
135
|
+
}); },
|
|
136
|
+
reorder({ ids } = {}) { return commit(() => {
|
|
137
|
+
if (!Array.isArray(ids)) throw new Error('ids must be a list');
|
|
138
|
+
const first = ids.map((id) => get(String(id)));
|
|
139
|
+
renumber([...new Set(first), ...sorted().filter((x) => !first.includes(x))]);
|
|
140
|
+
save(); return sorted();
|
|
141
|
+
}); },
|
|
142
|
+
remove({ id } = {}) { return commit(() => { const it = get(String(id)); items = items.filter((x) => x !== it); renumber(sorted()); save(); return it; }); },
|
|
143
|
+
// Undo of a delete in the window: the item goes back at its old place.
|
|
144
|
+
restore(x = {}) { return commit(() => this.restore0(x)); },
|
|
145
|
+
restore0({ item } = {}) {
|
|
146
|
+
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || items.some((x) => x.id === item.id)) throw new Error('nothing to restore');
|
|
147
|
+
if (!STATUSES.includes(item.status)) throw new Error('bad item');
|
|
148
|
+
const it = { ...item, title: str(item.title, MAX_TITLE, 'title', true) };
|
|
149
|
+
const list = sorted(); list.splice(Math.max(0, Math.min(list.length, (Number(item.order) || 1) - 1)), 0, it);
|
|
150
|
+
items = list; renumber(items); save(); return it;
|
|
151
|
+
},
|
|
152
|
+
clearFinished() { return commit(() => { items = items.filter((x) => !['done', 'failed', 'skipped'].includes(x.status)); renumber(sorted()); save(); return true; }); },
|
|
153
|
+
// status: done | failed | skipped | finished (all three) | all → the removed items (for Undo).
|
|
154
|
+
clear({ status = 'finished' } = {}) {
|
|
155
|
+
const pick = { done: ['done'], failed: ['failed'], skipped: ['skipped'], finished: ['done', 'failed', 'skipped'], 'failed+skipped': ['failed', 'skipped'], all: STATUSES }[status];
|
|
156
|
+
if (!pick) throw new Error('status must be done, failed, skipped or all');
|
|
157
|
+
return commit(() => {
|
|
158
|
+
const gone = sorted().filter((x) => pick.includes(x.status));
|
|
159
|
+
items = items.filter((x) => !gone.includes(x)); renumber(sorted()); save();
|
|
160
|
+
return gone;
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
restoreMany({ items: list } = {}) {
|
|
164
|
+
if (!Array.isArray(list)) throw new Error('nothing to restore');
|
|
165
|
+
return many(() => { for (const it of [...list].sort((a, b) => a.order - b.order)) this.restore0({ item: it }); return sorted(); });
|
|
166
|
+
},
|
|
167
|
+
addMany(list) {
|
|
168
|
+
if (!Array.isArray(list) || !list.length || list.length > 100) throw new Error('items must be a list of 1–100 tasks');
|
|
169
|
+
if (items.length + list.length > MAX_ITEMS) throw new Error(`the queue would be over ${MAX_ITEMS} items`);
|
|
170
|
+
// All or nothing: a half-added list would be added twice when the chat retries.
|
|
171
|
+
list.forEach((x, i) => { str(x?.title, MAX_TITLE, `items[${i}].title`, true); str(x?.details, MAX_DETAILS, `items[${i}].details`); paths(x?.paths); try { handoff(x); } catch (e) { throw new Error(`items[${i}]: ${e.message}`); } });
|
|
172
|
+
return many(() => list.map((x) => this.add0(x)));
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** queue_* tool call → MCP result. */
|
|
178
|
+
export function queueTool(q, name, args = {}) {
|
|
179
|
+
const ok = (v) => ({ content: [{ type: 'text', text: JSON.stringify(v, null, 2) }] });
|
|
180
|
+
try {
|
|
181
|
+
switch (name) {
|
|
182
|
+
case 'queue_list': return ok({ counts: q.counts(), items: q.list(args.status).map(({ id, title, status, note, worker, updatedAt, details, paths, from, resultPath }) => ({ id, title, status, ...(note ? { note } : {}), ...(worker ? { worker } : {}), updatedAt, ...(details ? { details } : {}), ...(paths ? { paths } : {}), ...(from ? { from } : {}), ...(resultPath ? { resultPath } : {}) })) });
|
|
183
|
+
case 'queue_next': { const it = q.next(args); return ok(it ? { item: it } : { item: null, message: 'The queue is empty — tell the person and stop.' }); }
|
|
184
|
+
case 'queue_update': return ok({ item: q.update(args) });
|
|
185
|
+
case 'queue_add': return args.items ? ok({ items: q.addMany(args.items) }) : ok({ item: q.add(args) });
|
|
186
|
+
case 'queue_clear': { if (args.status === 'all') throw new Error('clearing every item needs the person\'s OK'); const gone = q.clear({ status: args.status || 'finished' }); return ok({ removed: gone.length, counts: q.counts() }); }
|
|
187
|
+
case 'queue_edit': return ok({ item: q.edit(args) });
|
|
188
|
+
case 'queue_reorder': return ok({ items: q.reorder(args) });
|
|
189
|
+
default: return { content: [{ type: 'text', text: `unknown queue tool ${name}` }], isError: true };
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return { content: [{ type: 'text', text: `Queue: ${e.message}` }], isError: true };
|
|
193
|
+
}
|
|
194
|
+
}
|