dsh-composer-plus 1.17.0 → 1.19.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.de.md +24 -7
- package/README.es.md +24 -7
- package/README.fr.md +24 -7
- package/README.hi.md +24 -7
- package/README.id.md +24 -7
- package/README.ja.md +24 -7
- package/README.ko.md +24 -7
- package/README.md +24 -7
- package/README.pt-BR.md +24 -7
- package/README.ru.md +24 -7
- package/README.tr.md +24 -7
- package/README.vi.md +24 -7
- package/README.zh.md +24 -7
- package/assets/features/blockedCommands.webp +0 -0
- package/assets/features/plannedSteps.webp +0 -0
- package/assets/features/sessionBranch.webp +0 -0
- package/assets/features/taskBoard.webp +0 -0
- package/client.js +715 -73
- package/index.js +699 -3
- package/package.json +3 -2
- package/release-notes.json +18 -0
- package/tether/gateway/server.mjs +1 -0
package/index.js
CHANGED
|
@@ -65,14 +65,16 @@ export const SEND_FIRST_PATH = '/api/composer-plus.send-first';
|
|
|
65
65
|
export const MEMORY_PATH = '/api/composer-plus.memory';
|
|
66
66
|
export const MEMORY_EDIT_PATH = '/api/composer-plus.memory-edit';
|
|
67
67
|
export const PROTECTED_PATH = '/api/composer-plus.protected-files';
|
|
68
|
+
export const BLOCKED_PATH = '/api/composer-plus.blocked-commands';
|
|
68
69
|
export const HANDOFF_PATH = '/api/composer-plus.handoff';
|
|
69
70
|
export const CONTEXT_PATH = '/api/composer-plus.context';
|
|
71
|
+
export const PLANNED_PATH = '/api/composer-plus.planned-steps';
|
|
70
72
|
/** Tether's green dots: at most this many unread chat ids are kept (the oldest goes first). */
|
|
71
73
|
export const UNREAD_MAX = 1000;
|
|
72
74
|
/** Feature screenshots shipped in assets/features/<id>.webp; only these ids are served (no path input). */
|
|
73
75
|
export const SHOTS = ['changesPanel', 'featureIdeas', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'idleSwitch', 'allowForChat', 'continueButton', 'resumeAfterRestart', 'inputHistory', 'planToggle', 'keepFocus', 'questionFit', 'headerFit',
|
|
74
76
|
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'projectPicker', 'nextPrediction', 'typingCompletion', 'promptImprove', 'exportMarkdown', 'language', 'autoUpdate', 'contentSearch', 'turnRollback', 'deleteChat', 'projectName', 'markProjectRead', 'queueEdit', 'copyMessageLink', 'findInChat', 'sessionBranch', 'worktreeChats', 'branchTree', 'defaultWorkspace', 'goalCheck', 'projectMemory',
|
|
75
|
-
'backup', 'autoBackup', 'sessionDoctor', 'rescueConsole', 'stopSendFirst', 'protectedFiles', 'handoffChat', 'contextBreakdown'];
|
|
77
|
+
'backup', 'autoBackup', 'sessionDoctor', 'rescueConsole', 'stopSendFirst', 'protectedFiles', 'blockedCommands', 'handoffChat', 'contextBreakdown', 'taskBoard', 'plannedSteps'];
|
|
76
78
|
const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
77
79
|
const NPM_NAME = 'dsh-composer-plus';
|
|
78
80
|
const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
|
|
@@ -686,6 +688,19 @@ export const releaseNotes = (body) => {
|
|
|
686
688
|
const text = body.replace(/\r\n?/g, '\n').split(/^## Install\b/m)[0].trim().slice(0, NOTES_MAX);
|
|
687
689
|
return text === '' ? undefined : text;
|
|
688
690
|
};
|
|
691
|
+
/** A UI language id in `release-notes.json` (`en`, `zh`, `pt-BR`); other keys, such as `__proto__`, are not read. */
|
|
692
|
+
const NOTES_LANG_RE = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/;
|
|
693
|
+
/** The release notes that ship with this copy (`release-notes.json`: `{ version, notes: { [language]: markdown } }`), for
|
|
694
|
+
* What's new in the UI language: `{ [language]: text }`, or undefined when the file is for another version or has no
|
|
695
|
+
* text. The copy that runs after an update has them; the older copy that installed it saved only GitHub's English body. */
|
|
696
|
+
export const shippedNotes = (text, version = PACKAGE.version) => {
|
|
697
|
+
let data;
|
|
698
|
+
try { data = JSON.parse(text); } catch { return undefined; }
|
|
699
|
+
if (data?.version !== version || data.notes === null || typeof data.notes !== 'object') return undefined;
|
|
700
|
+
const notes = Object.fromEntries(Object.entries(data.notes).filter(([id]) => NOTES_LANG_RE.test(id))
|
|
701
|
+
.map(([id, body]) => [id, releaseNotes(body)]).filter(([, body]) => body !== undefined));
|
|
702
|
+
return Object.keys(notes).length === 0 ? undefined : notes;
|
|
703
|
+
};
|
|
689
704
|
/** Latest GitHub release vs this copy. `managed` is false for a renamed or linked local copy, which pnpm can't upgrade. */
|
|
690
705
|
export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
|
|
691
706
|
const current = parseVersion(pkg.version);
|
|
@@ -1896,8 +1911,12 @@ export function apply(ctx) {
|
|
|
1896
1911
|
ctx.connection.fetch.register({
|
|
1897
1912
|
path: UPDATE_PATH, methods: ['GET'], requestBody: 'buffered',
|
|
1898
1913
|
fetch: async (request) => {
|
|
1899
|
-
// `?current=1`:
|
|
1900
|
-
|
|
1914
|
+
// `?current=1`: the version that this Host runs and its shipped release notes (What's new after an update); no
|
|
1915
|
+
// network call. A missing or broken notes file leaves `notes` out.
|
|
1916
|
+
if (new URL(request.url).searchParams.get('current') === '1') {
|
|
1917
|
+
const notes = await readFile(new URL('./release-notes.json', import.meta.url), 'utf8').then(shippedNotes, () => undefined);
|
|
1918
|
+
return json(notes === undefined ? { current: PACKAGE.version } : { current: PACKAGE.version, notes });
|
|
1919
|
+
}
|
|
1901
1920
|
try { return json(await checkUpdate(fetch, PACKAGE, request.signal)); } catch (error) { return json({ error: String(error?.message ?? error) }, 502); }
|
|
1902
1921
|
},
|
|
1903
1922
|
});
|
|
@@ -2009,8 +2028,11 @@ export function apply(ctx) {
|
|
|
2009
2028
|
installDefaultWorkspace(ctx, home);
|
|
2010
2029
|
installMemory(ctx, home);
|
|
2011
2030
|
installProtectedFiles(ctx, home);
|
|
2031
|
+
installBlockedCommands(ctx, home);
|
|
2012
2032
|
installHandoff(ctx, home);
|
|
2013
2033
|
installContext(ctx);
|
|
2034
|
+
installBoard(ctx, home);
|
|
2035
|
+
installPlannedSteps(ctx, home);
|
|
2014
2036
|
installBackup(ctx, home);
|
|
2015
2037
|
}
|
|
2016
2038
|
|
|
@@ -4168,6 +4190,291 @@ export function installProtectedFiles(ctx, home, file = join(home, 'storages', '
|
|
|
4168
4190
|
});
|
|
4169
4191
|
}
|
|
4170
4192
|
|
|
4193
|
+
/* Blocked commands (after dsh-superagent's `blockedCommands`): the agent's shell tools (`bash`, `pwsh`) do not run a command
|
|
4194
|
+
* that matches one of the user's rules ("block"), or they ask the user first with dsh's own approval card ("ask first").
|
|
4195
|
+
* A `tools/pre-execute` gate, after every other gate. It reads the command text only: a variable (`$CMD`), an alias, a
|
|
4196
|
+
* function, a script file, another interpreter (`python -c`) or an MCP tool can still run the command, which the README
|
|
4197
|
+
* says. On by default with an empty list: it starts no work and opens no port; the Host keeps the switch and the list. */
|
|
4198
|
+
export const BLOCK_MAX = 50; // rules
|
|
4199
|
+
export const BLOCK_RULE_MAX = 200; // characters of one rule
|
|
4200
|
+
const BLOCK_DEPTH = 3; // `bash -c`, `eval` and here-document texts are read again, at most this deep
|
|
4201
|
+
const SHELL_NAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'su', 'pwsh', 'powershell']);
|
|
4202
|
+
const EVAL_NAMES = new Set(['eval', 'iex', 'invoke-expression']);
|
|
4203
|
+
/** `[{rule, mode: 'block'|'ask'}]` with single spaces; of two rules with the same words (any case) the first stays. Strict (a
|
|
4204
|
+
* save): undefined when the list is not valid. Not strict (the saved file): the valid rules, at most BLOCK_MAX. */
|
|
4205
|
+
export function parseRules(list, strict = true) {
|
|
4206
|
+
if (!Array.isArray(list) || (strict && list.length > BLOCK_MAX)) return undefined;
|
|
4207
|
+
const out = [];
|
|
4208
|
+
for (const r of list) {
|
|
4209
|
+
const raw = r?.rule;
|
|
4210
|
+
const ok = plainObject(r) && Object.keys(r).every((k) => k === 'rule' || k === 'mode') && (r.mode === 'block' || r.mode === 'ask')
|
|
4211
|
+
&& typeof raw === 'string' && raw.length <= BLOCK_RULE_MAX && !/[\0\r\n]/.test(raw) && raw.trim() !== '';
|
|
4212
|
+
if (!ok) { if (strict) return undefined; continue; }
|
|
4213
|
+
const rule = raw.trim().split(/\s+/).join(' ');
|
|
4214
|
+
if (!out.some((o) => o.rule.toLowerCase() === rule.toLowerCase())) out.push({ rule, mode: r.mode });
|
|
4215
|
+
if (out.length === BLOCK_MAX) break;
|
|
4216
|
+
}
|
|
4217
|
+
return out;
|
|
4218
|
+
}
|
|
4219
|
+
/** Saved settings `{on, rules}`: on with no rules when there is no file. */
|
|
4220
|
+
export const blockedStore = (file) => settingsStore(file, (v) => ({ on: v?.on !== false, rules: parseRules(v?.rules, false) ?? [] }));
|
|
4221
|
+
/** A command word as a name: its last path part, lower case, without `.exe`, `.cmd`, `.bat` or `.com`. */
|
|
4222
|
+
const commandName = (word) => word.slice(Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\')) + 1).toLowerCase().replace(/\.(exe|cmd|bat|com)$/, '');
|
|
4223
|
+
/** `*` any characters (also `/`), `?` one; both texts in lower case. A `*` of the rule is checked first, so it also matches
|
|
4224
|
+
* a `*` in the text (`rm *` matches `rm *.log`). The last-star method: at most |p|·|s| steps, never exponential (the gate
|
|
4225
|
+
* runs in the Host's event loop). ponytail: a rule like `*a*a*a…` (200 characters) against a very long word costs |p|·|s|;
|
|
4226
|
+
* the user writes the rules, and a rule without `*` stops at the first different character. */
|
|
4227
|
+
export function wildMatch(p, s) {
|
|
4228
|
+
let i = 0; let j = 0; let star = -1; let mark = 0;
|
|
4229
|
+
while (j < s.length) {
|
|
4230
|
+
if (i < p.length && p[i] === '*') { star = i; i += 1; mark = j; }
|
|
4231
|
+
else if (i < p.length && (p[i] === '?' || p[i] === s[j])) { i += 1; j += 1; }
|
|
4232
|
+
else if (star !== -1) { i = star + 1; mark += 1; j = mark; }
|
|
4233
|
+
else return false;
|
|
4234
|
+
}
|
|
4235
|
+
while (p[i] === '*') i += 1;
|
|
4236
|
+
return i === p.length;
|
|
4237
|
+
}
|
|
4238
|
+
/** The texts that a simple command runs again, found where the shell takes them (AGENTS.md rule 13), and the words after
|
|
4239
|
+
* `eval` (`iex` for pwsh). sh (case-sensitive): after a `-…c…` or `+…c…` group, the first word that is not an option (`--`
|
|
4240
|
+
* or `-` end the options; each `o`/`O` of a group takes a value, as in `-o pipefail`); a here-string (`bash <<< 'npm
|
|
4241
|
+
* publish'`). pwsh (any case, PowerShell's `MatchSwitch`): `-`, `--` or `/` and a prefix of `command` (`-c`, `-co`, …): the
|
|
4242
|
+
* rest of the words; `-cwa`/`-CommandWithArgs`: the next word. One pass, so a long command stays linear.
|
|
4243
|
+
* ponytail: a word that is not an option before `-c` (a script file, su's user) does not end the search, so `bash x.sh -c
|
|
4244
|
+
* 'y'` also reads `y`; that can only make a rule match more often. */
|
|
4245
|
+
function rereads(words) {
|
|
4246
|
+
const out = [];
|
|
4247
|
+
let shell = null; // 'sh' or 'ps' after a shell name, until its script
|
|
4248
|
+
let c = false; let skip = 0; // sh: a `-…c…` group was read; option values still to pass
|
|
4249
|
+
const take = (script, ps) => { if (script !== undefined && script !== '') out.push([script, ps]); shell = null; };
|
|
4250
|
+
for (let k = 0; k < words.length; k++) {
|
|
4251
|
+
const word = words[k];
|
|
4252
|
+
const w = word.toLowerCase();
|
|
4253
|
+
if (shell === 'sh') {
|
|
4254
|
+
if (skip > 0) { skip -= 1; continue; }
|
|
4255
|
+
if (w === '--' || w === '-') { if (c) { take(words[k + 1], false); k += 1; } continue; }
|
|
4256
|
+
if (/^[-+][A-Za-z]+$/.test(word)) { c ||= word.includes('c'); skip = word.replace(/[^oO]/g, '').length; continue; }
|
|
4257
|
+
if (w.startsWith('--')) continue;
|
|
4258
|
+
if (w.startsWith('<<<')) { take(w === '<<<' ? words[k + 1] : word.slice(3), false); k += 1; continue; }
|
|
4259
|
+
if (c) { take(word, false); continue; }
|
|
4260
|
+
} else if (shell === 'ps') {
|
|
4261
|
+
const key = w.replace(/^(--?|\/)/, '');
|
|
4262
|
+
if (key !== w && key !== '' && 'command'.startsWith(key)) { take(words.slice(k + 1).join(' '), true); break; }
|
|
4263
|
+
if (key === 'cwa' || key === 'commandwithargs') { take(words[k + 1], true); k += 1; continue; }
|
|
4264
|
+
}
|
|
4265
|
+
const name = commandName(w);
|
|
4266
|
+
if (EVAL_NAMES.has(name)) { if (k + 1 < words.length) out.push([words.slice(k + 1).join(' '), name !== 'eval']); break; }
|
|
4267
|
+
if (SHELL_NAMES.has(name)) { shell = name === 'pwsh' || name === 'powershell' ? 'ps' : 'sh'; c = false; skip = 0; }
|
|
4268
|
+
}
|
|
4269
|
+
return out;
|
|
4270
|
+
}
|
|
4271
|
+
/** The simple commands of a shell text, each as its words (quotes removed). bash: `; & | ( )`, a newline and `{ }` at a word
|
|
4272
|
+
* start end a command; `'…'`, `"…"` and `\`; `$(…)` and backticks (also inside "…") are commands of their own; `#` at a
|
|
4273
|
+
* word start is a comment; a here-document body is skipped, unless its delimiter is unquoted (then only its `$(…)` and
|
|
4274
|
+
* backticks count). pwsh (`pwsh`): the same separators, the backtick escape, `''` and `""` inside quotes, `$(…)`. The
|
|
4275
|
+
* texts of `bash -c`/`eval` (see rereads) and of here-documents are read again, at most BLOCK_DEPTH deep. Never throws;
|
|
4276
|
+
* linear in the text length (each level reads at most the text once).
|
|
4277
|
+
* ponytail: not a shell parser: `case x in a)`, nested backticks and aliases are not understood; a cut command can only
|
|
4278
|
+
* make a rule match more often (or, past BLOCK_DEPTH, not at all; the README says). */
|
|
4279
|
+
export function shellCommands(text, pwsh = false, depth = 0, hereText = false) {
|
|
4280
|
+
const commands = [];
|
|
4281
|
+
if (typeof text !== 'string' || depth > BLOCK_DEPTH) return commands;
|
|
4282
|
+
const esc = pwsh ? '`' : '\\';
|
|
4283
|
+
let words = []; let word = ''; let has = false;
|
|
4284
|
+
let quote = hereText ? 'h' : null; // null, "'", '"' or 'h' (the text of a here-document: only substitutions are commands)
|
|
4285
|
+
let close = null; let parens = 0; let top = hereText; // `top`: this level is text, not commands
|
|
4286
|
+
const stack = []; // the outer command while a $( … ) or ` … ` is read
|
|
4287
|
+
const pending = []; // here-documents whose body starts at the next newline
|
|
4288
|
+
let lineShell = false; // a command of this line names a shell: its here-document bodies are commands (`bash <<'EOF'`)
|
|
4289
|
+
const endWord = () => { if (has) { words.push(word); word = ''; has = false; } };
|
|
4290
|
+
const endCommand = () => {
|
|
4291
|
+
endWord();
|
|
4292
|
+
if (words.length > 0 && !top) {
|
|
4293
|
+
if (words.some((w) => SHELL_NAMES.has(commandName(w)))) lineShell = true;
|
|
4294
|
+
commands.push(words);
|
|
4295
|
+
for (const [script, ps] of rereads(words)) for (const c of shellCommands(script, ps, depth + 1)) commands.push(c);
|
|
4296
|
+
}
|
|
4297
|
+
words = [];
|
|
4298
|
+
};
|
|
4299
|
+
const open = (c) => { stack.push({ words, word, quote, close, parens, top }); words = []; word = ''; has = false; quote = null; close = c; parens = 0; top = false; };
|
|
4300
|
+
const shut = () => { endCommand(); ({ words, word, quote, close, parens, top } = stack.pop()); has = true; };
|
|
4301
|
+
const hereStart = (j) => {
|
|
4302
|
+
let dash = false;
|
|
4303
|
+
if (text[j] === '-') { dash = true; j += 1; }
|
|
4304
|
+
while (text[j] === ' ' || text[j] === '\t') j += 1;
|
|
4305
|
+
let delim = ''; let quoted = false; let q = null;
|
|
4306
|
+
for (; j < text.length; j++) {
|
|
4307
|
+
const d = text[j];
|
|
4308
|
+
if (q !== null) { if (d === q) q = null; else delim += d; continue; }
|
|
4309
|
+
if (d === "'" || d === '"') { q = d; quoted = true; continue; }
|
|
4310
|
+
if (d === '\\') { quoted = true; if (j + 1 < text.length) { j += 1; delim += text[j]; } continue; }
|
|
4311
|
+
if (' \t\n;&|()<>'.includes(d)) break;
|
|
4312
|
+
delim += d;
|
|
4313
|
+
}
|
|
4314
|
+
if (delim !== '' || quoted) pending.push({ delim, dash, quoted });
|
|
4315
|
+
return j;
|
|
4316
|
+
};
|
|
4317
|
+
// The start of each line by its text (and by its text without leading tabs, for `<<-`), made once: finding an end line
|
|
4318
|
+
// is a binary search, so many here-documents without an end line stay linear.
|
|
4319
|
+
let lineIndex;
|
|
4320
|
+
const endLine = (delim, dash, pos) => {
|
|
4321
|
+
if (lineIndex === undefined) {
|
|
4322
|
+
lineIndex = [new Map(), new Map()];
|
|
4323
|
+
for (let s = 0; s <= text.length;) {
|
|
4324
|
+
const nl = text.indexOf('\n', s); const e = nl === -1 ? text.length : nl;
|
|
4325
|
+
const line = text.slice(s, e);
|
|
4326
|
+
for (const [k, key] of [[0, line], [1, line.replace(/^\t+/, '')]]) { const at = lineIndex[k].get(key); if (at === undefined) lineIndex[k].set(key, [s]); else at.push(s); }
|
|
4327
|
+
if (nl === -1) break;
|
|
4328
|
+
s = nl + 1;
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
const at = lineIndex[dash ? 1 : 0].get(delim);
|
|
4332
|
+
if (at === undefined) return -1;
|
|
4333
|
+
let lo = 0; let hi = at.length;
|
|
4334
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (at[mid] < pos) lo = mid + 1; else hi = mid; }
|
|
4335
|
+
return lo < at.length ? at[lo] : -1;
|
|
4336
|
+
};
|
|
4337
|
+
// The bodies of the pending here-documents, from `pos` (the line after `<<`); answers the index after the last one. A
|
|
4338
|
+
// body with no end line is read as commands (fail safe: `$((1<<2))` is no here-document).
|
|
4339
|
+
const hereBodies = (pos) => {
|
|
4340
|
+
for (const { delim, dash, quoted } of pending.splice(0)) {
|
|
4341
|
+
const begin = pos;
|
|
4342
|
+
const end = endLine(delim, dash, pos);
|
|
4343
|
+
if (end === -1) break;
|
|
4344
|
+
const nl = text.indexOf('\n', end);
|
|
4345
|
+
pos = nl === -1 ? text.length : nl + 1;
|
|
4346
|
+
if (lineShell || !quoted) for (const c of shellCommands(text.slice(begin, end), false, depth + 1, !lineShell)) commands.push(c);
|
|
4347
|
+
}
|
|
4348
|
+
lineShell = false;
|
|
4349
|
+
return pos;
|
|
4350
|
+
};
|
|
4351
|
+
for (let i = 0; i < text.length; i++) {
|
|
4352
|
+
const c = text[i];
|
|
4353
|
+
if (quote === "'") {
|
|
4354
|
+
if (c !== "'") word += c;
|
|
4355
|
+
else if (pwsh && text[i + 1] === "'") { word += "'"; i += 1; } else quote = null;
|
|
4356
|
+
continue;
|
|
4357
|
+
}
|
|
4358
|
+
if (quote === '"' || quote === 'h') {
|
|
4359
|
+
if (c === esc && i + 1 < text.length) {
|
|
4360
|
+
i += 1;
|
|
4361
|
+
const n = text[i];
|
|
4362
|
+
if (n === '\n') continue; // a line goes on
|
|
4363
|
+
if (pwsh || n === '$' || n === '`' || n === '\\' || (quote === '"' && n === '"')) word += n; else word += c + n;
|
|
4364
|
+
continue;
|
|
4365
|
+
}
|
|
4366
|
+
if (c === '"' && quote === '"') { if (pwsh && text[i + 1] === '"') { word += '"'; i += 1; } else quote = null; continue; }
|
|
4367
|
+
if (c === '$' && text[i + 1] === '(') { open(')'); i += 1; continue; }
|
|
4368
|
+
if (c === '`' && !pwsh) { open('`'); continue; }
|
|
4369
|
+
word += c;
|
|
4370
|
+
continue;
|
|
4371
|
+
}
|
|
4372
|
+
if (c === esc && i + 1 < text.length) { i += 1; if (text[i] !== '\n') { word += text[i]; has = true; } continue; }
|
|
4373
|
+
if (c === "'" || c === '"') { quote = c; has = true; continue; }
|
|
4374
|
+
if (c === '$' && text[i + 1] === '(') { open(')'); i += 1; continue; }
|
|
4375
|
+
if (c === '`' && !pwsh) { if (close === '`') shut(); else open('`'); continue; }
|
|
4376
|
+
if (c === ' ' || c === '\t' || c === '\r') { endWord(); continue; }
|
|
4377
|
+
if (c === '\n' || c === ';' || c === '&' || c === '|') {
|
|
4378
|
+
endCommand();
|
|
4379
|
+
if (c === '\n') { if (pending.length > 0) i = hereBodies(i + 1) - 1; lineShell = false; }
|
|
4380
|
+
continue;
|
|
4381
|
+
}
|
|
4382
|
+
if (c === '(') { endCommand(); parens += 1; continue; }
|
|
4383
|
+
if (c === ')') { if (parens > 0) { parens -= 1; endCommand(); } else if (close === ')') shut(); else endCommand(); continue; }
|
|
4384
|
+
if (!has && (c === '{' || c === '}') && (i + 1 === text.length || ' \t\r\n;&|)'.includes(text[i + 1]))) { endCommand(); continue; }
|
|
4385
|
+
if (!has && c === '#') { const n = text.indexOf('\n', i); i = (n === -1 ? text.length : n) - 1; continue; }
|
|
4386
|
+
if (!pwsh && c === '<' && text[i + 1] === '<') {
|
|
4387
|
+
if (text[i + 2] === '<') { word += '<<<'; has = true; i += 2; continue; } // a here-string: its word is text
|
|
4388
|
+
endWord(); i = hereStart(i + 2) - 1; continue;
|
|
4389
|
+
}
|
|
4390
|
+
word += c; has = true;
|
|
4391
|
+
}
|
|
4392
|
+
for (;;) {
|
|
4393
|
+
endCommand();
|
|
4394
|
+
if (stack.length === 0) break;
|
|
4395
|
+
({ words, word, quote, close, parens, top } = stack.pop());
|
|
4396
|
+
has = true;
|
|
4397
|
+
}
|
|
4398
|
+
return commands;
|
|
4399
|
+
}
|
|
4400
|
+
/** The rule that a shell command breaks: `{rule, mode, command}` (`command`: the simple command, its words joined), else
|
|
4401
|
+
* undefined. A rule's first word is a command name: any word of a simple command (so `sudo npm publish`, `env A=1 npm
|
|
4402
|
+
* publish` and `/usr/bin/npm publish` match `npm publish`); its other words must follow in the same order, with other
|
|
4403
|
+
* words between them allowed. A block rule wins over an ask rule; else the first rule in the list. */
|
|
4404
|
+
export function blockedHit(rules, command, pwsh = false) {
|
|
4405
|
+
if (!Array.isArray(rules) || rules.length === 0 || typeof command !== 'string') return undefined;
|
|
4406
|
+
const commands = shellCommands(command, pwsh).map((words) => ({ words, lower: words.map((w) => w.toLowerCase()), names: words.map(commandName) }));
|
|
4407
|
+
let ask;
|
|
4408
|
+
for (const r of rules) {
|
|
4409
|
+
const want = r.rule.toLowerCase().split(' ');
|
|
4410
|
+
const first = commandName(want[0]);
|
|
4411
|
+
for (const { words, lower, names } of commands) {
|
|
4412
|
+
const start = names.findIndex((n) => wildMatch(first, n));
|
|
4413
|
+
if (start === -1) continue;
|
|
4414
|
+
let x = 1;
|
|
4415
|
+
for (let k = start + 1; k < lower.length && x < want.length; k++) if (wildMatch(want[x], lower[k])) x += 1;
|
|
4416
|
+
if (x < want.length) continue;
|
|
4417
|
+
const hit = { rule: r.rule, mode: r.mode, command: words.join(' ') };
|
|
4418
|
+
if (r.mode === 'block') return hit;
|
|
4419
|
+
ask ??= hit;
|
|
4420
|
+
break;
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
return ask;
|
|
4424
|
+
}
|
|
4425
|
+
const shownCommand = (text) => {
|
|
4426
|
+
const s = text.slice(0, 400).replace(/[\u0000-\u001f\u007f]+/g, ' ');
|
|
4427
|
+
return s.length > 160 ? `${s.slice(0, 159)}…` : s;
|
|
4428
|
+
};
|
|
4429
|
+
export function installBlockedCommands(ctx, home, file = join(home, 'storages', 'dsh-composer-plus', 'blocked-commands.json')) {
|
|
4430
|
+
const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
|
|
4431
|
+
const store = blockedStore(file);
|
|
4432
|
+
let settings;
|
|
4433
|
+
const read = store.read().then((s) => { settings = s; });
|
|
4434
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
4435
|
+
const decision = await next();
|
|
4436
|
+
// A denied or cancelled call stays so; only an allowed call and one that would ask are checked.
|
|
4437
|
+
if ((decision?.kind !== 'allow' && decision?.kind !== 'ask') || (exec.name !== 'bash' && exec.name !== 'pwsh')) return decision;
|
|
4438
|
+
await read;
|
|
4439
|
+
if (!settings.on || settings.rules.length === 0) return decision;
|
|
4440
|
+
const hit = blockedHit(settings.rules, argsOf(exec.arguments)?.command, exec.name === 'pwsh');
|
|
4441
|
+
if (hit === undefined) return decision;
|
|
4442
|
+
const shown = shownCommand(hit.command);
|
|
4443
|
+
// A chat whose approval policy is `never` (Full access, a delegated child) cannot ask: dsh would reject the ask with no
|
|
4444
|
+
// reason, so the agent reads this one instead. The effective policy is the chat's own `approval/policy` event, else the
|
|
4445
|
+
// configured default (dsh-user-approval `effectivePolicy`; `never` when dsh starts with DSH_PERMISSION_MODE=danger-full-access).
|
|
4446
|
+
let never = false;
|
|
4447
|
+
try {
|
|
4448
|
+
const approval = get('approval'); const session = exec.agent?.session;
|
|
4449
|
+
const policy = typeof approval?.effectivePolicy === 'function' ? approval.effectivePolicy(session) : approval?.overrideOf?.(session) ?? approval?.config?.policy;
|
|
4450
|
+
never = policy === 'never';
|
|
4451
|
+
} catch { never = false; }
|
|
4452
|
+
if (hit.mode === 'block' || never) {
|
|
4453
|
+
const why = hit.mode === 'ask' ? ' (ask first, and this chat cannot ask: its approval policy is never)' : '';
|
|
4454
|
+
return { kind: 'deny', reason: `Blocked command: ${shown} matches "${hit.rule}"${why} in Composer Plus → Blocked commands. Do not run it in another way (a script, another shell, a variable). If the task needs it, ask the user.` };
|
|
4455
|
+
}
|
|
4456
|
+
// dsh's approval card shows this reason. It must not start like an Auto review ask: Allow for this chat answers those.
|
|
4457
|
+
const own = `Composer Plus → Blocked commands: ${shown} matches "${hit.rule}" (ask first).`;
|
|
4458
|
+
return { kind: 'ask', reason: decision.kind === 'ask' && typeof decision.reason === 'string' && decision.reason !== '' ? `${own} ${decision.reason}` : own };
|
|
4459
|
+
});
|
|
4460
|
+
ctx.connection.fetch.register({
|
|
4461
|
+
path: BLOCKED_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
4462
|
+
fetch: async (request) => {
|
|
4463
|
+
await read;
|
|
4464
|
+
if (request.method === 'POST') {
|
|
4465
|
+
let body;
|
|
4466
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
4467
|
+
if (!plainObject(body)) return json({ error: 'bad request' }, 400);
|
|
4468
|
+
const keys = Object.keys(body).sort().join(',');
|
|
4469
|
+
const rules = keys === 'rules' ? parseRules(body.rules) : undefined;
|
|
4470
|
+
if (!(keys === 'on' && typeof body.on === 'boolean') && rules === undefined) return json({ error: 'bad request' }, 400);
|
|
4471
|
+
try { settings = await store.change((s) => (rules === undefined ? { ...s, on: body.on } : { ...s, rules })); } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
4472
|
+
}
|
|
4473
|
+
return json(settings);
|
|
4474
|
+
},
|
|
4475
|
+
});
|
|
4476
|
+
}
|
|
4477
|
+
|
|
4171
4478
|
/** Worktree chats: a `.worktreeinclude` larger than this is refused (a cut pattern could name more than the user wrote). */
|
|
4172
4479
|
export const INCLUDE_MAX = 64 * 1024;
|
|
4173
4480
|
const WORKTREES_MAX = 20;
|
|
@@ -6125,3 +6432,392 @@ export function installBackup(ctx, home, { graceMs = AUTO_BACKUP_GRACE_MS, retry
|
|
|
6125
6432
|
});
|
|
6126
6433
|
return { settled: () => new Promise((resolve) => { const wait = () => (busy === null ? resolve() : setTimeout(wait, 10)); wait(); }), tick };
|
|
6127
6434
|
}
|
|
6435
|
+
|
|
6436
|
+
/** Task board (Host half): a kanban board of dsh's real chats. The client draws the cards from dsh's own stores (the chat
|
|
6437
|
+
* list, each chat's live state and the projects); the Host keeps the switch and the record of the chats that the board
|
|
6438
|
+
* started: `storages/dsh-composer-plus/board-chats.json`, `{ chats: { [sessionId]: { prompt, tags, at } } }`, owner-only.
|
|
6439
|
+
* A board start makes a new chat in a dsh project with the user's prompt (dsh's own title service names the chat); one
|
|
6440
|
+
* short call to the new chat's model writes its tags. The old notebook board (`board.json`) is not read or changed. */
|
|
6441
|
+
export const BOARD_PATH = '/api/composer-plus.board';
|
|
6442
|
+
export const BOARD_CHATS_PATH = '/api/composer-plus.board-chats';
|
|
6443
|
+
export const BOARD_LIMITS = { prompt: 20000, tags: 3, tag: 24, chats: 2000 };
|
|
6444
|
+
const BOARD_TAG_INPUT = 4000;
|
|
6445
|
+
const BOARD_TAG_TOKENS = 200;
|
|
6446
|
+
const BOARD_TAG_TIMEOUT_MS = 60000;
|
|
6447
|
+
export const BOARD_TAG_SYSTEM = `You label a coding task for a kanban board. Read the user's request and answer with only a JSON array of 1 to ${BOARD_LIMITS.tags} short tags, for example ["frontend","bug"]. Each tag is one or two lower-case words, at most ${BOARD_LIMITS.tag} characters, in the language of the request. No other text.`;
|
|
6448
|
+
const BRANCH_DIR = /^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/].*)?$/;
|
|
6449
|
+
const trimSlash = (p) => (p.length > 1 ? p.replace(/[\\/]+$/, '') : p);
|
|
6450
|
+
const boardLine = (raw) => String(raw ?? '').replace(/\s+/g, ' ').trim();
|
|
6451
|
+
|
|
6452
|
+
/** The model's tags: the first JSON array of strings, each one line, lower case, at most BOARD_LIMITS.tag characters, no
|
|
6453
|
+
* repeats, at most BOARD_LIMITS.tags. Any other answer gives []. */
|
|
6454
|
+
export function boardTags(text) {
|
|
6455
|
+
const m = /\[[\s\S]*?\]/.exec(String(text ?? '').replace(/<think>[\s\S]*?<\/think>/g, ''));
|
|
6456
|
+
let list;
|
|
6457
|
+
try { list = m === null ? [] : JSON.parse(m[0]); } catch { list = []; }
|
|
6458
|
+
if (!Array.isArray(list)) return [];
|
|
6459
|
+
const out = [];
|
|
6460
|
+
for (const item of list) {
|
|
6461
|
+
if (typeof item !== 'string') continue;
|
|
6462
|
+
const tag = boardLine(item).replace(/^[#"'`\-*\s]+|["'`.\s]+$/g, '').toLowerCase();
|
|
6463
|
+
if (tag === '' || tag.length > BOARD_LIMITS.tag || out.includes(tag)) continue;
|
|
6464
|
+
out.push(tag);
|
|
6465
|
+
if (out.length === BOARD_LIMITS.tags) break;
|
|
6466
|
+
}
|
|
6467
|
+
return out;
|
|
6468
|
+
}
|
|
6469
|
+
|
|
6470
|
+
/** The record of the board's chats: one change at a time, merged into the newest data (a start never drops another
|
|
6471
|
+
* start's entry), each write atomic and owner-only. A missing file is empty; a broken file is kept as
|
|
6472
|
+
* `board-chats.json.broken-<time>` and the record starts empty. At most BOARD_LIMITS.chats entries (the oldest go). */
|
|
6473
|
+
export function boardChatsStore(file) {
|
|
6474
|
+
let chats = {};
|
|
6475
|
+
let rev = Date.now();
|
|
6476
|
+
const ready = (async () => {
|
|
6477
|
+
let text;
|
|
6478
|
+
try { text = await readFile(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return; throw error; }
|
|
6479
|
+
try {
|
|
6480
|
+
const raw = JSON.parse(text)?.chats;
|
|
6481
|
+
if (!plainObject(raw)) throw new Error('not an object');
|
|
6482
|
+
for (const [id, c] of Object.entries(raw)) {
|
|
6483
|
+
if (!isText(id, 128) || id === '__proto__' || !plainObject(c) || typeof c.prompt !== 'string' || !Number.isFinite(c.at)) continue;
|
|
6484
|
+
chats[id] = { prompt: c.prompt.slice(0, BOARD_LIMITS.prompt), tags: Array.isArray(c.tags) ? c.tags.filter((t) => isText(t, BOARD_LIMITS.tag)).slice(0, BOARD_LIMITS.tags) : [], at: c.at };
|
|
6485
|
+
}
|
|
6486
|
+
} catch { chats = {}; await rename(file, `${file}.broken-${Date.now()}`).catch(() => {}); }
|
|
6487
|
+
})();
|
|
6488
|
+
let queue = ready.catch(() => {});
|
|
6489
|
+
return {
|
|
6490
|
+
ready,
|
|
6491
|
+
rev: () => rev,
|
|
6492
|
+
list: () => chats,
|
|
6493
|
+
/** `fn(copy)` changes a copy of the record; the copy becomes the record only after the write. */
|
|
6494
|
+
change(fn) {
|
|
6495
|
+
const done = queue.then(async () => {
|
|
6496
|
+
await ready;
|
|
6497
|
+
const next = { ...chats };
|
|
6498
|
+
fn(next);
|
|
6499
|
+
const ids = Object.keys(next);
|
|
6500
|
+
if (ids.length > BOARD_LIMITS.chats) for (const id of ids.sort((a, b) => next[a].at - next[b].at).slice(0, ids.length - BOARD_LIMITS.chats)) delete next[id];
|
|
6501
|
+
await mkdir(dirname(file), { recursive: true });
|
|
6502
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
6503
|
+
await writeFile(tmp, JSON.stringify({ chats: next }), { mode: 0o600 });
|
|
6504
|
+
await rename(tmp, file);
|
|
6505
|
+
chats = next; rev += 1;
|
|
6506
|
+
});
|
|
6507
|
+
queue = done.catch(() => {});
|
|
6508
|
+
return done;
|
|
6509
|
+
},
|
|
6510
|
+
};
|
|
6511
|
+
}
|
|
6512
|
+
|
|
6513
|
+
/** The dsh project of a board start, by its workspace id: a registered workspace that is not a Branch per chat folder. */
|
|
6514
|
+
export function boardProject(registry, id) {
|
|
6515
|
+
if (!isText(id)) return undefined;
|
|
6516
|
+
let list;
|
|
6517
|
+
try { list = registry?.list?.() ?? []; } catch { list = []; }
|
|
6518
|
+
return list.find((w) => w?.id === id && isText(w.path, 4096) && !BRANCH_DIR.test(trimSlash(w.path)));
|
|
6519
|
+
}
|
|
6520
|
+
|
|
6521
|
+
/** One short call to `route` for the tags of a prompt; [] when the call fails or answers nothing usable. */
|
|
6522
|
+
export async function boardTagsFor({ llm, route }, prompt, signal) {
|
|
6523
|
+
if (llm?.prepareCall === undefined || route === undefined) return [];
|
|
6524
|
+
const timeout = AbortSignal.timeout(BOARD_TAG_TIMEOUT_MS);
|
|
6525
|
+
const callSignal = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
6526
|
+
const call = await llm.prepareCall(route, callSignal, (controls, model) => {
|
|
6527
|
+
// The lowest reasoning effort keeps it fast; the output cap never goes above the model's own default.
|
|
6528
|
+
const first = model?.reasoning?.efforts?.[0];
|
|
6529
|
+
const cap = Number.isSafeInteger(model?.defaultMaxTokens) && model.defaultMaxTokens > 0 ? Math.min(BOARD_TAG_TOKENS, model.defaultMaxTokens) : BOARD_TAG_TOKENS;
|
|
6530
|
+
return { ...controls, maxTokens: cap, ...(first === undefined ? {} : { reasoningEffort: first.id }) };
|
|
6531
|
+
});
|
|
6532
|
+
let text = '';
|
|
6533
|
+
let finish;
|
|
6534
|
+
for await (const chunk of call.stream({ ...call.config, system: BOARD_TAG_SYSTEM, signal: callSignal,
|
|
6535
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: String(prompt).slice(0, BOARD_TAG_INPUT) }] }] })) {
|
|
6536
|
+
if (chunk.type === 'text-delta') text += chunk.text;
|
|
6537
|
+
else if (chunk.type === 'finish') finish = chunk.reason;
|
|
6538
|
+
}
|
|
6539
|
+
if (finish?.kind === 'error' || finish?.kind === 'aborted') throw new Error(finish.failure?.message || `the model call ended: ${finish.kind}`);
|
|
6540
|
+
return boardTags(text);
|
|
6541
|
+
}
|
|
6542
|
+
|
|
6543
|
+
/** Start a board task: a new chat in the project's dsh workspace with `prompt` as its first message (a `/command` that dsh
|
|
6544
|
+
* knows runs as that command), then the record. All or none: when the prompt cannot be sent, the new chat is archived and
|
|
6545
|
+
* the error says so. The prompt is sent with its own timeout, not the request's signal, so a closed request does not leave
|
|
6546
|
+
* an empty chat. Returns `{ sessionId, agent }`. */
|
|
6547
|
+
export async function startBoardChat({ sessionController, commands, registry, store }, project, prompt) {
|
|
6548
|
+
const text = String(prompt ?? '').trim();
|
|
6549
|
+
if (text === '' || text.length > BOARD_LIMITS.prompt) throw refused(`a task needs a prompt of at most ${BOARD_LIMITS.prompt} characters`, 400);
|
|
6550
|
+
const workspace = boardProject(registry, project);
|
|
6551
|
+
if (workspace === undefined) throw refused('this is not a dsh project', 404);
|
|
6552
|
+
const { sessionId } = await sessionController.create({ workspaceId: workspace.id });
|
|
6553
|
+
let agent;
|
|
6554
|
+
try {
|
|
6555
|
+
const found = await sessionController.resolveAgent(sessionId);
|
|
6556
|
+
if ('error' in found) throw new Error('the new chat could not start');
|
|
6557
|
+
agent = found.agent;
|
|
6558
|
+
const name = /^\/([\w:.-]+)(?:\s|$)/.exec(text)?.[1];
|
|
6559
|
+
let done;
|
|
6560
|
+
if (name !== undefined && commands?.find?.(agent, name) !== undefined) {
|
|
6561
|
+
done = await commands.execute(agent, text, [], AbortSignal.timeout(STOP_WAIT_MS));
|
|
6562
|
+
if (done?.result?.kind !== 'success') throw new Error(`/${name} failed${done?.result?.text ? `: ${done.result.text}` : ''}`);
|
|
6563
|
+
} else await sessionController.prompt({ requestId: randomUUID(), sessionId, mode: 'queue', content: [{ type: 'text', text }] }, AbortSignal.timeout(STOP_WAIT_MS));
|
|
6564
|
+
await store.change((chats) => { chats[sessionId] = { prompt: text, tags: [], at: Date.now() }; });
|
|
6565
|
+
} catch (error) {
|
|
6566
|
+
const undone = await Promise.resolve(registry?.archiveSession?.(sessionId, { stopActivity: true })).then(() => registry?.archiveSession !== undefined, () => false);
|
|
6567
|
+
throw Object.assign(new Error(`${error?.message ?? error}; ${undone ? 'the new chat was archived' : `the new chat ${sessionId} stays empty`}`), { status: Number.isInteger(error?.status) ? error.status : 502 });
|
|
6568
|
+
}
|
|
6569
|
+
return { sessionId, agent };
|
|
6570
|
+
}
|
|
6571
|
+
|
|
6572
|
+
/** Task board (Host half): the switch route and, while it is on, the board's chat route. */
|
|
6573
|
+
export function installBoard(ctx, home, dir = join(home, 'storages', 'dsh-composer-plus')) {
|
|
6574
|
+
const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
|
|
6575
|
+
const store = boardChatsStore(join(dir, 'board-chats.json'));
|
|
6576
|
+
const sw = mobileSwitch(join(dir, 'board-switch.json'), true);
|
|
6577
|
+
let on = true;
|
|
6578
|
+
const ready = sw.read().then((v) => { on = v; }, () => {});
|
|
6579
|
+
let closed = false;
|
|
6580
|
+
const tagging = new Set(); // background tag calls; unload stops them
|
|
6581
|
+
ctx.effect?.(() => () => { closed = true; for (const c of tagging) c.abort(); }, 'dsh-composer-plus: task board');
|
|
6582
|
+
/** The tags of a new chat, in the background: the chat's own model (its selection), else dsh's default. */
|
|
6583
|
+
const tag = async (sessionId, agent, prompt) => {
|
|
6584
|
+
const llm = get('llm');
|
|
6585
|
+
if (llm?.prepareCall === undefined || closed) return;
|
|
6586
|
+
const mine = new AbortController();
|
|
6587
|
+
tagging.add(mine);
|
|
6588
|
+
try {
|
|
6589
|
+
const current = ctx.sessionController.agents?.selectionFor?.(agent)?.current;
|
|
6590
|
+
const route = isText(current?.provider) && isText(current?.model) ? { provider: current.provider, model: current.model } : await routeOf(ctx, get);
|
|
6591
|
+
const tags = await boardTagsFor({ llm, route }, prompt, mine.signal);
|
|
6592
|
+
if (tags.length > 0 && !closed) await store.change((chats) => { if (chats[sessionId] !== undefined) chats[sessionId] = { ...chats[sessionId], tags }; });
|
|
6593
|
+
} catch (error) { if (!closed) ctx.logger?.warn?.(`dsh-composer-plus: task board tags failed: ${String(error?.message ?? error).slice(0, 300)}`); }
|
|
6594
|
+
finally { tagging.delete(mine); }
|
|
6595
|
+
};
|
|
6596
|
+
let switching = ready;
|
|
6597
|
+
ctx.connection.fetch.register({
|
|
6598
|
+
path: BOARD_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
6599
|
+
fetch: async (request) => {
|
|
6600
|
+
await ready;
|
|
6601
|
+
if (request.method === 'POST') {
|
|
6602
|
+
let body;
|
|
6603
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
6604
|
+
if (!plainObject(body) || Object.keys(body).join() !== 'on' || typeof body.on !== 'boolean') return json({ error: 'bad request' }, 400);
|
|
6605
|
+
try {
|
|
6606
|
+
const change = switching.then(async () => { await sw.write(body.on); on = body.on; });
|
|
6607
|
+
switching = change.catch(() => {});
|
|
6608
|
+
await change;
|
|
6609
|
+
} catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
6610
|
+
}
|
|
6611
|
+
return json({ on });
|
|
6612
|
+
},
|
|
6613
|
+
});
|
|
6614
|
+
/** GET `?rev=N`: `{rev, chats}` (`{rev, same}` when nothing changed since rev). POST `{project, prompt}`: start a chat. */
|
|
6615
|
+
ctx.connection.fetch.register({
|
|
6616
|
+
path: BOARD_CHATS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
6617
|
+
fetch: async (request) => {
|
|
6618
|
+
await ready;
|
|
6619
|
+
if (!on) return json({ error: 'Task board is off' }, 409);
|
|
6620
|
+
try {
|
|
6621
|
+
await store.ready;
|
|
6622
|
+
if (request.method === 'GET') {
|
|
6623
|
+
const rev = store.rev();
|
|
6624
|
+
if (new URL(request.url).searchParams.get('rev') === String(rev)) return json({ rev, same: true });
|
|
6625
|
+
return json({ rev, chats: store.list() });
|
|
6626
|
+
}
|
|
6627
|
+
let body;
|
|
6628
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
6629
|
+
if (!plainObject(body) || !isText(body.project) || typeof body.prompt !== 'string' || body.prompt.length > BOARD_LIMITS.prompt + 1000) return json({ error: 'bad request' }, 400);
|
|
6630
|
+
const { sessionId, agent } = await startBoardChat({ sessionController: ctx.sessionController, commands: get('commands'), registry: get('workspaceRegistry'), store }, body.project, body.prompt);
|
|
6631
|
+
tag(sessionId, agent, body.prompt.trim());
|
|
6632
|
+
return json({ rev: store.rev(), sessionId });
|
|
6633
|
+
} catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
|
|
6634
|
+
},
|
|
6635
|
+
});
|
|
6636
|
+
return { store, ready, tag, isOn: () => on };
|
|
6637
|
+
}
|
|
6638
|
+
|
|
6639
|
+
/* Planned steps, after HarnessMM (arXiv 2610.32813, "guarded decision reuse"): the model plans its next commands with the
|
|
6640
|
+
* exact arguments and a guard (`plan_next_steps`). When the newest tool results meet the guard of exactly one step, Composer
|
|
6641
|
+
* Plus answers the agent's next model request itself (dsh's `llm/stream` waterfall) with one assistant message: a short
|
|
6642
|
+
* reasoning block (only when the model's own planning message has one: DeepSeek's thinking mode refuses a history message
|
|
6643
|
+
* without it), a text line and the step's tool call. dsh then runs the call on its normal path (tools/pre-execute: approval,
|
|
6644
|
+
* sandbox, Protected files). Stateless: the plan is the tool call's arguments in the request history, so a restart, a fork,
|
|
6645
|
+
* a rollback or /compact needs no code of its own. No step, two ready steps, a failed guard or any user message → the model. */
|
|
6646
|
+
export const PLANNED_TOOL = 'plan_next_steps';
|
|
6647
|
+
export const PLANNED_LIMITS = { steps: 8, args: 8 * 1024 };
|
|
6648
|
+
export const PLANNED_STEP_TOOLS = ['bash', 'read', 'grep', 'glob'];
|
|
6649
|
+
const PLANNED_GUARDS = ['ok', 'always'];
|
|
6650
|
+
const PLANNED_ID = /^[a-z0-9-]{1,32}$/;
|
|
6651
|
+
/** Composer Plus's own tool-call ids: `cpgs-<8 hex of the plan call's id>-<step number>`. */
|
|
6652
|
+
const PLANNED_CALL = /^cpgs-([0-9a-f]{8})-([1-9]\d?)$/;
|
|
6653
|
+
const PLANNED_SECTION = `# Planned steps (Composer Plus)
|
|
6654
|
+
When you already know the exact next commands and only a tool result decides whether they run (for example: build, then test when the build passes), call \`${PLANNED_TOOL}\` in the same message as the first command. Composer Plus runs each planned step without asking you again when its predecessor finished and its guard holds.
|
|
6655
|
+
- \`after\`: "root" (the other tool calls of that message) or the id of an earlier step.
|
|
6656
|
+
- \`when\`: "ok" (the predecessor succeeded; for bash: no \`[exit code: N]\` or other fail mark) or "always".
|
|
6657
|
+
- Tools: ${PLANNED_STEP_TOOLS.join(', ')}. Write the complete, literal arguments. Do not plan a step whose arguments depend on output that you did not see.
|
|
6658
|
+
You see every result. When a guard fails, when two steps are ready at the same time, or after the last step, you answer again. A new user message ends the plan.`;
|
|
6659
|
+
|
|
6660
|
+
/** The steps of one `plan_next_steps` call, checked; throws a plain reason (the tool's error text) for a bad plan. */
|
|
6661
|
+
export function parsePlan(args) {
|
|
6662
|
+
const fail = (message) => { throw new Error(message); };
|
|
6663
|
+
if (!plainObject(args) || !Array.isArray(args.steps)) fail('"steps" must be a list of steps');
|
|
6664
|
+
if (args.steps.length < 1 || args.steps.length > PLANNED_LIMITS.steps) fail(`a plan has 1 to ${PLANNED_LIMITS.steps} steps`);
|
|
6665
|
+
const ids = new Set();
|
|
6666
|
+
return args.steps.map((s, i) => {
|
|
6667
|
+
const at = `step ${i + 1}`;
|
|
6668
|
+
if (!plainObject(s)) fail(`${at} must be an object`);
|
|
6669
|
+
if (typeof s.id !== 'string' || !PLANNED_ID.test(s.id) || s.id === 'root') fail(`${at}: "id" must be 1 to 32 of a-z, 0-9 and "-" (not "root")`);
|
|
6670
|
+
if (ids.has(s.id)) fail(`${at}: the id "${s.id}" is used twice`);
|
|
6671
|
+
const after = s.after ?? 'root';
|
|
6672
|
+
if (after !== 'root' && !ids.has(after)) fail(`${at}: "after" must be "root" or the id of an earlier step`);
|
|
6673
|
+
const when = s.when ?? 'ok';
|
|
6674
|
+
if (!PLANNED_GUARDS.includes(when)) fail(`${at}: "when" must be "ok" or "always"`);
|
|
6675
|
+
if (!PLANNED_STEP_TOOLS.includes(s.tool)) fail(`${at}: "tool" must be one of ${PLANNED_STEP_TOOLS.join(', ')}`);
|
|
6676
|
+
if (!plainObject(s.arguments)) fail(`${at}: "arguments" must be an object`);
|
|
6677
|
+
if (s.tool === 'bash' && s.arguments.run_in_background === true) fail(`${at}: a planned bash step runs in the foreground (its result is the guard)`);
|
|
6678
|
+
// dsh's bash tool needs a description; it is only the label in the chat.
|
|
6679
|
+
const args2 = s.tool === 'bash' && typeof s.arguments.description !== 'string' ? { description: `Planned step ${s.id}`, ...s.arguments } : s.arguments;
|
|
6680
|
+
if (JSON.stringify(args2).length > PLANNED_LIMITS.args) fail(`${at}: "arguments" are longer than ${PLANNED_LIMITS.args} characters`);
|
|
6681
|
+
ids.add(s.id);
|
|
6682
|
+
return { id: s.id, after, when, tool: s.tool, args: args2 };
|
|
6683
|
+
});
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
/** One finished call passed: not an error, and for bash no fail mark of dsh in the last 8 lines (the Goal check rule). */
|
|
6687
|
+
const plannedOk = (tool, result) => result !== undefined && result.isError !== true
|
|
6688
|
+
&& (tool !== 'bash' || !BASH_FAIL.test(textBlocks(result.content).split('\n').slice(-8).join('\n')));
|
|
6689
|
+
const planTag = (callId) => createHash('sha256').update(String(callId)).digest('hex').slice(0, 8);
|
|
6690
|
+
|
|
6691
|
+
/** The step to run now for a model request's `messages`, or undefined (then the model answers). `tools`: the names that the
|
|
6692
|
+
* request offers. Reads back from the end: tool results, dsh's runtime context, developer and system messages are neutral;
|
|
6693
|
+
* Composer Plus's own step messages are counted; the newest model message before them must hold an accepted plan. Any other
|
|
6694
|
+
* user message (the user, steer, Continue, /compact, a rollback marker) ends the plan. */
|
|
6695
|
+
export function nextPlannedStep(messages, tools) {
|
|
6696
|
+
if (!Array.isArray(messages)) return undefined;
|
|
6697
|
+
const results = new Map();
|
|
6698
|
+
const ran = [];
|
|
6699
|
+
let i = messages.length - 1;
|
|
6700
|
+
for (; i >= 0; i--) {
|
|
6701
|
+
const m = messages[i];
|
|
6702
|
+
if (m?.role === 'tool') { results.set(m.toolCallId ?? m.source?.callId, m); continue; }
|
|
6703
|
+
if (m?.role === 'developer' || m?.role === 'system') continue;
|
|
6704
|
+
if (m?.role === 'user') { if (m.source?.kind === 'runtime-context') continue; return undefined; }
|
|
6705
|
+
if (m?.role !== 'assistant') return undefined;
|
|
6706
|
+
const calls = (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type === 'tool-call');
|
|
6707
|
+
if (calls.length === 1 && PLANNED_CALL.test(calls[0].id)) { ran.push(calls[0]); continue; }
|
|
6708
|
+
break;
|
|
6709
|
+
}
|
|
6710
|
+
if (i < 0 || !Array.isArray(messages[i].content)) return undefined;
|
|
6711
|
+
const planned = messages[i];
|
|
6712
|
+
const calls = planned.content.filter((b) => b?.type === 'tool-call');
|
|
6713
|
+
const planCall = calls.find((c) => c.name === PLANNED_TOOL);
|
|
6714
|
+
if (planCall === undefined || !plannedOk(PLANNED_TOOL, results.get(planCall.id))) return undefined;
|
|
6715
|
+
let steps;
|
|
6716
|
+
try { steps = parsePlan(argsOf(planCall.arguments)); } catch { return undefined; }
|
|
6717
|
+
const tag = planTag(planCall.id);
|
|
6718
|
+
// `root`: the other calls of the planning message (the plan call alone when there are none); all of them have results.
|
|
6719
|
+
const rootCalls = calls.length > 1 ? calls.filter((c) => c !== planCall) : [planCall];
|
|
6720
|
+
if (!rootCalls.every((c) => results.has(c.id))) return undefined;
|
|
6721
|
+
const done = new Map([['root', rootCalls.every((c) => plannedOk(c.name, results.get(c.id)))]]);
|
|
6722
|
+
for (const call of ran) {
|
|
6723
|
+
const [, t, n] = call.id.match(PLANNED_CALL);
|
|
6724
|
+
const step = steps[Number(n) - 1];
|
|
6725
|
+
if (t !== tag || step === undefined || done.has(step.id) || !results.has(call.id)) return undefined;
|
|
6726
|
+
done.set(step.id, plannedOk(step.tool, results.get(call.id)));
|
|
6727
|
+
}
|
|
6728
|
+
const ready = steps.map((step, k) => ({ step, n: k + 1 })).filter(({ step }) => !done.has(step.id) && done.has(step.after)
|
|
6729
|
+
&& (step.when === 'always' || done.get(step.after) === true) && (tools === undefined || tools.has(step.tool)));
|
|
6730
|
+
if (ready.length !== 1) return undefined;
|
|
6731
|
+
return { ...ready[0], tag, reasoning: planned.content.some((b) => b?.type === 'reasoning') };
|
|
6732
|
+
}
|
|
6733
|
+
|
|
6734
|
+
/** The chunks of the assistant message that runs `found` (the answer of nextPlannedStep). */
|
|
6735
|
+
export function plannedStepChunks(found) {
|
|
6736
|
+
const { step, n, tag, reasoning } = found;
|
|
6737
|
+
const id = `cpgs-${tag}-${n}`;
|
|
6738
|
+
const args = JSON.stringify(step.args);
|
|
6739
|
+
const blocks = [];
|
|
6740
|
+
if (reasoning) blocks.push({ type: 'reasoning', text: `Composer Plus ran step "${step.id}" of an earlier ${PLANNED_TOOL} plan because its guard "${step.when}" held after "${step.after}". The model did not reason for this step.` });
|
|
6741
|
+
blocks.push({ type: 'text', text: `Composer Plus · Planned steps: ran \`${step.id}\` (after \`${step.after}\`, guard ${step.when}) without a model call.` });
|
|
6742
|
+
const chunks = [];
|
|
6743
|
+
blocks.forEach((b, index) => {
|
|
6744
|
+
chunks.push({ type: 'block-start', index, blockType: b.type });
|
|
6745
|
+
chunks.push({ type: b.type === 'text' ? 'text-delta' : 'reasoning-delta', index, text: b.text });
|
|
6746
|
+
chunks.push({ type: 'block-end', index, block: b });
|
|
6747
|
+
});
|
|
6748
|
+
const index = blocks.length;
|
|
6749
|
+
chunks.push({ type: 'block-start', index, blockType: 'tool-call' });
|
|
6750
|
+
chunks.push({ type: 'tool-call-delta', index, id, name: step.tool, argumentsDelta: args });
|
|
6751
|
+
chunks.push({ type: 'block-end', index, block: { type: 'tool-call', id, name: step.tool, arguments: args } });
|
|
6752
|
+
chunks.push({ type: 'finish', reason: { kind: 'tool-calls' } });
|
|
6753
|
+
return chunks;
|
|
6754
|
+
}
|
|
6755
|
+
|
|
6756
|
+
/** The `llm/stream` listener: an agent request of a chat (it has a session, no purpose such as compaction, and offers the
|
|
6757
|
+
* plan tool) with one ready step gets the step's message; every other request goes on to the model. */
|
|
6758
|
+
export function plannedStream(options, next) {
|
|
6759
|
+
let found;
|
|
6760
|
+
try {
|
|
6761
|
+
const tools = Array.isArray(options?.tools) ? new Set(options.tools.map((t) => t?.name)) : undefined;
|
|
6762
|
+
if (options?.sessionId !== undefined && options.purpose === undefined && tools?.has(PLANNED_TOOL)) found = nextPlannedStep(options.messages, tools);
|
|
6763
|
+
} catch { found = undefined; }
|
|
6764
|
+
if (found === undefined) return next();
|
|
6765
|
+
const chunks = plannedStepChunks(found);
|
|
6766
|
+
return (async function* () { yield* chunks; })();
|
|
6767
|
+
}
|
|
6768
|
+
|
|
6769
|
+
/** Planned steps (Host half): the switch route (off by default), and while it is on: the tool, the prompt section and the
|
|
6770
|
+
* `llm/stream` listener, all in one fiber that switching off disposes. */
|
|
6771
|
+
export function installPlannedSteps(ctx, home, file = join(home, 'storages', 'dsh-composer-plus', 'planned-steps.json')) {
|
|
6772
|
+
const sw = mobileSwitch(file);
|
|
6773
|
+
let on = false;
|
|
6774
|
+
let fiber;
|
|
6775
|
+
const tool = {
|
|
6776
|
+
name: PLANNED_TOOL,
|
|
6777
|
+
description: `Plan your next tool calls when you already know their exact arguments and only a tool result decides whether they run. Composer Plus runs each step without a new model call when its predecessor finished and its guard holds; you see every result. Call it in the same message as the first command. Steps: ${PLANNED_STEP_TOOLS.join(', ')} only.`,
|
|
6778
|
+
parameters: { type: 'object', properties: {
|
|
6779
|
+
steps: { type: 'array', description: `1 to ${PLANNED_LIMITS.steps} steps, in order`, items: { type: 'object', properties: {
|
|
6780
|
+
id: { type: 'string', description: 'A short name: a-z, 0-9 and "-", such as "test"' },
|
|
6781
|
+
after: { type: 'string', description: '"root" (the default: the other tool calls of this message) or the id of an earlier step' },
|
|
6782
|
+
when: { type: 'string', enum: PLANNED_GUARDS, description: '"ok" (the default): only when the predecessor succeeded; "always": when it finished' },
|
|
6783
|
+
tool: { type: 'string', enum: PLANNED_STEP_TOOLS },
|
|
6784
|
+
arguments: { type: 'object', description: 'The complete, literal arguments of the tool call' },
|
|
6785
|
+
}, required: ['id', 'tool', 'arguments'] } },
|
|
6786
|
+
}, required: ['steps'] },
|
|
6787
|
+
output: textOutput,
|
|
6788
|
+
async execute(args) {
|
|
6789
|
+
const steps = parsePlan(args);
|
|
6790
|
+
return `Plan accepted: ${steps.map((s) => `${s.id} (${s.tool}, after ${s.after}, when ${s.when})`).join('; ')}. Composer Plus runs a step without a model call when its predecessor finished and its guard holds. A failed guard, two ready steps, the last step or a new user message gives the turn back to you.`;
|
|
6791
|
+
},
|
|
6792
|
+
};
|
|
6793
|
+
const apply = (next) => {
|
|
6794
|
+
on = next;
|
|
6795
|
+
if (on && fiber === undefined && typeof ctx.inject === 'function') {
|
|
6796
|
+
fiber = ctx.inject(['tools', 'systemPrompt'], (sub) => {
|
|
6797
|
+
sub.tools.register(tool);
|
|
6798
|
+
sub.systemPrompt.section({ name: 'composer-plus:planned-steps', order: 660, interpolate: false, text: () => PLANNED_SECTION });
|
|
6799
|
+
sub.on('llm/stream', plannedStream);
|
|
6800
|
+
});
|
|
6801
|
+
} else if (!on) { fiber?.dispose?.(); fiber = undefined; }
|
|
6802
|
+
};
|
|
6803
|
+
const ready = sw.read().then(apply, () => apply(false));
|
|
6804
|
+
ctx.effect?.(() => () => { on = false; fiber?.dispose?.(); fiber = undefined; }, 'dsh-composer-plus: planned steps');
|
|
6805
|
+
// One switch change at a time, in order: the saved file and the live state always end on the newest click.
|
|
6806
|
+
let switching = ready;
|
|
6807
|
+
ctx.connection.fetch.register({
|
|
6808
|
+
path: PLANNED_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
6809
|
+
fetch: async (request) => {
|
|
6810
|
+
await ready;
|
|
6811
|
+
if (request.method === 'POST') {
|
|
6812
|
+
let body;
|
|
6813
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
6814
|
+
if (!plainObject(body) || Object.keys(body).join() !== 'on' || typeof body.on !== 'boolean') return json({ error: 'bad request' }, 400);
|
|
6815
|
+
const change = switching.then(async () => { await sw.write(body.on); apply(body.on); });
|
|
6816
|
+
switching = change.catch(() => {});
|
|
6817
|
+
try { await change; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
6818
|
+
}
|
|
6819
|
+
return json({ on });
|
|
6820
|
+
},
|
|
6821
|
+
});
|
|
6822
|
+
return { ready, tool };
|
|
6823
|
+
}
|