dsh-composer-plus 1.17.0 → 1.18.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/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';
@@ -2009,8 +2011,11 @@ export function apply(ctx) {
2009
2011
  installDefaultWorkspace(ctx, home);
2010
2012
  installMemory(ctx, home);
2011
2013
  installProtectedFiles(ctx, home);
2014
+ installBlockedCommands(ctx, home);
2012
2015
  installHandoff(ctx, home);
2013
2016
  installContext(ctx);
2017
+ installBoard(ctx, home);
2018
+ installPlannedSteps(ctx, home);
2014
2019
  installBackup(ctx, home);
2015
2020
  }
2016
2021
 
@@ -4168,6 +4173,291 @@ export function installProtectedFiles(ctx, home, file = join(home, 'storages', '
4168
4173
  });
4169
4174
  }
4170
4175
 
4176
+ /* Blocked commands (after dsh-superagent's `blockedCommands`): the agent's shell tools (`bash`, `pwsh`) do not run a command
4177
+ * that matches one of the user's rules ("block"), or they ask the user first with dsh's own approval card ("ask first").
4178
+ * A `tools/pre-execute` gate, after every other gate. It reads the command text only: a variable (`$CMD`), an alias, a
4179
+ * function, a script file, another interpreter (`python -c`) or an MCP tool can still run the command, which the README
4180
+ * says. On by default with an empty list: it starts no work and opens no port; the Host keeps the switch and the list. */
4181
+ export const BLOCK_MAX = 50; // rules
4182
+ export const BLOCK_RULE_MAX = 200; // characters of one rule
4183
+ const BLOCK_DEPTH = 3; // `bash -c`, `eval` and here-document texts are read again, at most this deep
4184
+ const SHELL_NAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'su', 'pwsh', 'powershell']);
4185
+ const EVAL_NAMES = new Set(['eval', 'iex', 'invoke-expression']);
4186
+ /** `[{rule, mode: 'block'|'ask'}]` with single spaces; of two rules with the same words (any case) the first stays. Strict (a
4187
+ * save): undefined when the list is not valid. Not strict (the saved file): the valid rules, at most BLOCK_MAX. */
4188
+ export function parseRules(list, strict = true) {
4189
+ if (!Array.isArray(list) || (strict && list.length > BLOCK_MAX)) return undefined;
4190
+ const out = [];
4191
+ for (const r of list) {
4192
+ const raw = r?.rule;
4193
+ const ok = plainObject(r) && Object.keys(r).every((k) => k === 'rule' || k === 'mode') && (r.mode === 'block' || r.mode === 'ask')
4194
+ && typeof raw === 'string' && raw.length <= BLOCK_RULE_MAX && !/[\0\r\n]/.test(raw) && raw.trim() !== '';
4195
+ if (!ok) { if (strict) return undefined; continue; }
4196
+ const rule = raw.trim().split(/\s+/).join(' ');
4197
+ if (!out.some((o) => o.rule.toLowerCase() === rule.toLowerCase())) out.push({ rule, mode: r.mode });
4198
+ if (out.length === BLOCK_MAX) break;
4199
+ }
4200
+ return out;
4201
+ }
4202
+ /** Saved settings `{on, rules}`: on with no rules when there is no file. */
4203
+ export const blockedStore = (file) => settingsStore(file, (v) => ({ on: v?.on !== false, rules: parseRules(v?.rules, false) ?? [] }));
4204
+ /** A command word as a name: its last path part, lower case, without `.exe`, `.cmd`, `.bat` or `.com`. */
4205
+ const commandName = (word) => word.slice(Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\')) + 1).toLowerCase().replace(/\.(exe|cmd|bat|com)$/, '');
4206
+ /** `*` any characters (also `/`), `?` one; both texts in lower case. A `*` of the rule is checked first, so it also matches
4207
+ * a `*` in the text (`rm *` matches `rm *.log`). The last-star method: at most |p|·|s| steps, never exponential (the gate
4208
+ * runs in the Host's event loop). ponytail: a rule like `*a*a*a…` (200 characters) against a very long word costs |p|·|s|;
4209
+ * the user writes the rules, and a rule without `*` stops at the first different character. */
4210
+ export function wildMatch(p, s) {
4211
+ let i = 0; let j = 0; let star = -1; let mark = 0;
4212
+ while (j < s.length) {
4213
+ if (i < p.length && p[i] === '*') { star = i; i += 1; mark = j; }
4214
+ else if (i < p.length && (p[i] === '?' || p[i] === s[j])) { i += 1; j += 1; }
4215
+ else if (star !== -1) { i = star + 1; mark += 1; j = mark; }
4216
+ else return false;
4217
+ }
4218
+ while (p[i] === '*') i += 1;
4219
+ return i === p.length;
4220
+ }
4221
+ /** The texts that a simple command runs again, found where the shell takes them (AGENTS.md rule 13), and the words after
4222
+ * `eval` (`iex` for pwsh). sh (case-sensitive): after a `-…c…` or `+…c…` group, the first word that is not an option (`--`
4223
+ * or `-` end the options; each `o`/`O` of a group takes a value, as in `-o pipefail`); a here-string (`bash <<< 'npm
4224
+ * publish'`). pwsh (any case, PowerShell's `MatchSwitch`): `-`, `--` or `/` and a prefix of `command` (`-c`, `-co`, …): the
4225
+ * rest of the words; `-cwa`/`-CommandWithArgs`: the next word. One pass, so a long command stays linear.
4226
+ * 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
4227
+ * 'y'` also reads `y`; that can only make a rule match more often. */
4228
+ function rereads(words) {
4229
+ const out = [];
4230
+ let shell = null; // 'sh' or 'ps' after a shell name, until its script
4231
+ let c = false; let skip = 0; // sh: a `-…c…` group was read; option values still to pass
4232
+ const take = (script, ps) => { if (script !== undefined && script !== '') out.push([script, ps]); shell = null; };
4233
+ for (let k = 0; k < words.length; k++) {
4234
+ const word = words[k];
4235
+ const w = word.toLowerCase();
4236
+ if (shell === 'sh') {
4237
+ if (skip > 0) { skip -= 1; continue; }
4238
+ if (w === '--' || w === '-') { if (c) { take(words[k + 1], false); k += 1; } continue; }
4239
+ if (/^[-+][A-Za-z]+$/.test(word)) { c ||= word.includes('c'); skip = word.replace(/[^oO]/g, '').length; continue; }
4240
+ if (w.startsWith('--')) continue;
4241
+ if (w.startsWith('<<<')) { take(w === '<<<' ? words[k + 1] : word.slice(3), false); k += 1; continue; }
4242
+ if (c) { take(word, false); continue; }
4243
+ } else if (shell === 'ps') {
4244
+ const key = w.replace(/^(--?|\/)/, '');
4245
+ if (key !== w && key !== '' && 'command'.startsWith(key)) { take(words.slice(k + 1).join(' '), true); break; }
4246
+ if (key === 'cwa' || key === 'commandwithargs') { take(words[k + 1], true); k += 1; continue; }
4247
+ }
4248
+ const name = commandName(w);
4249
+ if (EVAL_NAMES.has(name)) { if (k + 1 < words.length) out.push([words.slice(k + 1).join(' '), name !== 'eval']); break; }
4250
+ if (SHELL_NAMES.has(name)) { shell = name === 'pwsh' || name === 'powershell' ? 'ps' : 'sh'; c = false; skip = 0; }
4251
+ }
4252
+ return out;
4253
+ }
4254
+ /** The simple commands of a shell text, each as its words (quotes removed). bash: `; & | ( )`, a newline and `{ }` at a word
4255
+ * start end a command; `'…'`, `"…"` and `\`; `$(…)` and backticks (also inside "…") are commands of their own; `#` at a
4256
+ * word start is a comment; a here-document body is skipped, unless its delimiter is unquoted (then only its `$(…)` and
4257
+ * backticks count). pwsh (`pwsh`): the same separators, the backtick escape, `''` and `""` inside quotes, `$(…)`. The
4258
+ * texts of `bash -c`/`eval` (see rereads) and of here-documents are read again, at most BLOCK_DEPTH deep. Never throws;
4259
+ * linear in the text length (each level reads at most the text once).
4260
+ * ponytail: not a shell parser: `case x in a)`, nested backticks and aliases are not understood; a cut command can only
4261
+ * make a rule match more often (or, past BLOCK_DEPTH, not at all; the README says). */
4262
+ export function shellCommands(text, pwsh = false, depth = 0, hereText = false) {
4263
+ const commands = [];
4264
+ if (typeof text !== 'string' || depth > BLOCK_DEPTH) return commands;
4265
+ const esc = pwsh ? '`' : '\\';
4266
+ let words = []; let word = ''; let has = false;
4267
+ let quote = hereText ? 'h' : null; // null, "'", '"' or 'h' (the text of a here-document: only substitutions are commands)
4268
+ let close = null; let parens = 0; let top = hereText; // `top`: this level is text, not commands
4269
+ const stack = []; // the outer command while a $( … ) or ` … ` is read
4270
+ const pending = []; // here-documents whose body starts at the next newline
4271
+ let lineShell = false; // a command of this line names a shell: its here-document bodies are commands (`bash <<'EOF'`)
4272
+ const endWord = () => { if (has) { words.push(word); word = ''; has = false; } };
4273
+ const endCommand = () => {
4274
+ endWord();
4275
+ if (words.length > 0 && !top) {
4276
+ if (words.some((w) => SHELL_NAMES.has(commandName(w)))) lineShell = true;
4277
+ commands.push(words);
4278
+ for (const [script, ps] of rereads(words)) for (const c of shellCommands(script, ps, depth + 1)) commands.push(c);
4279
+ }
4280
+ words = [];
4281
+ };
4282
+ const open = (c) => { stack.push({ words, word, quote, close, parens, top }); words = []; word = ''; has = false; quote = null; close = c; parens = 0; top = false; };
4283
+ const shut = () => { endCommand(); ({ words, word, quote, close, parens, top } = stack.pop()); has = true; };
4284
+ const hereStart = (j) => {
4285
+ let dash = false;
4286
+ if (text[j] === '-') { dash = true; j += 1; }
4287
+ while (text[j] === ' ' || text[j] === '\t') j += 1;
4288
+ let delim = ''; let quoted = false; let q = null;
4289
+ for (; j < text.length; j++) {
4290
+ const d = text[j];
4291
+ if (q !== null) { if (d === q) q = null; else delim += d; continue; }
4292
+ if (d === "'" || d === '"') { q = d; quoted = true; continue; }
4293
+ if (d === '\\') { quoted = true; if (j + 1 < text.length) { j += 1; delim += text[j]; } continue; }
4294
+ if (' \t\n;&|()<>'.includes(d)) break;
4295
+ delim += d;
4296
+ }
4297
+ if (delim !== '' || quoted) pending.push({ delim, dash, quoted });
4298
+ return j;
4299
+ };
4300
+ // The start of each line by its text (and by its text without leading tabs, for `<<-`), made once: finding an end line
4301
+ // is a binary search, so many here-documents without an end line stay linear.
4302
+ let lineIndex;
4303
+ const endLine = (delim, dash, pos) => {
4304
+ if (lineIndex === undefined) {
4305
+ lineIndex = [new Map(), new Map()];
4306
+ for (let s = 0; s <= text.length;) {
4307
+ const nl = text.indexOf('\n', s); const e = nl === -1 ? text.length : nl;
4308
+ const line = text.slice(s, e);
4309
+ 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); }
4310
+ if (nl === -1) break;
4311
+ s = nl + 1;
4312
+ }
4313
+ }
4314
+ const at = lineIndex[dash ? 1 : 0].get(delim);
4315
+ if (at === undefined) return -1;
4316
+ let lo = 0; let hi = at.length;
4317
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (at[mid] < pos) lo = mid + 1; else hi = mid; }
4318
+ return lo < at.length ? at[lo] : -1;
4319
+ };
4320
+ // The bodies of the pending here-documents, from `pos` (the line after `<<`); answers the index after the last one. A
4321
+ // body with no end line is read as commands (fail safe: `$((1<<2))` is no here-document).
4322
+ const hereBodies = (pos) => {
4323
+ for (const { delim, dash, quoted } of pending.splice(0)) {
4324
+ const begin = pos;
4325
+ const end = endLine(delim, dash, pos);
4326
+ if (end === -1) break;
4327
+ const nl = text.indexOf('\n', end);
4328
+ pos = nl === -1 ? text.length : nl + 1;
4329
+ if (lineShell || !quoted) for (const c of shellCommands(text.slice(begin, end), false, depth + 1, !lineShell)) commands.push(c);
4330
+ }
4331
+ lineShell = false;
4332
+ return pos;
4333
+ };
4334
+ for (let i = 0; i < text.length; i++) {
4335
+ const c = text[i];
4336
+ if (quote === "'") {
4337
+ if (c !== "'") word += c;
4338
+ else if (pwsh && text[i + 1] === "'") { word += "'"; i += 1; } else quote = null;
4339
+ continue;
4340
+ }
4341
+ if (quote === '"' || quote === 'h') {
4342
+ if (c === esc && i + 1 < text.length) {
4343
+ i += 1;
4344
+ const n = text[i];
4345
+ if (n === '\n') continue; // a line goes on
4346
+ if (pwsh || n === '$' || n === '`' || n === '\\' || (quote === '"' && n === '"')) word += n; else word += c + n;
4347
+ continue;
4348
+ }
4349
+ if (c === '"' && quote === '"') { if (pwsh && text[i + 1] === '"') { word += '"'; i += 1; } else quote = null; continue; }
4350
+ if (c === '$' && text[i + 1] === '(') { open(')'); i += 1; continue; }
4351
+ if (c === '`' && !pwsh) { open('`'); continue; }
4352
+ word += c;
4353
+ continue;
4354
+ }
4355
+ if (c === esc && i + 1 < text.length) { i += 1; if (text[i] !== '\n') { word += text[i]; has = true; } continue; }
4356
+ if (c === "'" || c === '"') { quote = c; has = true; continue; }
4357
+ if (c === '$' && text[i + 1] === '(') { open(')'); i += 1; continue; }
4358
+ if (c === '`' && !pwsh) { if (close === '`') shut(); else open('`'); continue; }
4359
+ if (c === ' ' || c === '\t' || c === '\r') { endWord(); continue; }
4360
+ if (c === '\n' || c === ';' || c === '&' || c === '|') {
4361
+ endCommand();
4362
+ if (c === '\n') { if (pending.length > 0) i = hereBodies(i + 1) - 1; lineShell = false; }
4363
+ continue;
4364
+ }
4365
+ if (c === '(') { endCommand(); parens += 1; continue; }
4366
+ if (c === ')') { if (parens > 0) { parens -= 1; endCommand(); } else if (close === ')') shut(); else endCommand(); continue; }
4367
+ if (!has && (c === '{' || c === '}') && (i + 1 === text.length || ' \t\r\n;&|)'.includes(text[i + 1]))) { endCommand(); continue; }
4368
+ if (!has && c === '#') { const n = text.indexOf('\n', i); i = (n === -1 ? text.length : n) - 1; continue; }
4369
+ if (!pwsh && c === '<' && text[i + 1] === '<') {
4370
+ if (text[i + 2] === '<') { word += '<<<'; has = true; i += 2; continue; } // a here-string: its word is text
4371
+ endWord(); i = hereStart(i + 2) - 1; continue;
4372
+ }
4373
+ word += c; has = true;
4374
+ }
4375
+ for (;;) {
4376
+ endCommand();
4377
+ if (stack.length === 0) break;
4378
+ ({ words, word, quote, close, parens, top } = stack.pop());
4379
+ has = true;
4380
+ }
4381
+ return commands;
4382
+ }
4383
+ /** The rule that a shell command breaks: `{rule, mode, command}` (`command`: the simple command, its words joined), else
4384
+ * undefined. A rule's first word is a command name: any word of a simple command (so `sudo npm publish`, `env A=1 npm
4385
+ * publish` and `/usr/bin/npm publish` match `npm publish`); its other words must follow in the same order, with other
4386
+ * words between them allowed. A block rule wins over an ask rule; else the first rule in the list. */
4387
+ export function blockedHit(rules, command, pwsh = false) {
4388
+ if (!Array.isArray(rules) || rules.length === 0 || typeof command !== 'string') return undefined;
4389
+ const commands = shellCommands(command, pwsh).map((words) => ({ words, lower: words.map((w) => w.toLowerCase()), names: words.map(commandName) }));
4390
+ let ask;
4391
+ for (const r of rules) {
4392
+ const want = r.rule.toLowerCase().split(' ');
4393
+ const first = commandName(want[0]);
4394
+ for (const { words, lower, names } of commands) {
4395
+ const start = names.findIndex((n) => wildMatch(first, n));
4396
+ if (start === -1) continue;
4397
+ let x = 1;
4398
+ for (let k = start + 1; k < lower.length && x < want.length; k++) if (wildMatch(want[x], lower[k])) x += 1;
4399
+ if (x < want.length) continue;
4400
+ const hit = { rule: r.rule, mode: r.mode, command: words.join(' ') };
4401
+ if (r.mode === 'block') return hit;
4402
+ ask ??= hit;
4403
+ break;
4404
+ }
4405
+ }
4406
+ return ask;
4407
+ }
4408
+ const shownCommand = (text) => {
4409
+ const s = text.slice(0, 400).replace(/[\u0000-\u001f\u007f]+/g, ' ');
4410
+ return s.length > 160 ? `${s.slice(0, 159)}…` : s;
4411
+ };
4412
+ export function installBlockedCommands(ctx, home, file = join(home, 'storages', 'dsh-composer-plus', 'blocked-commands.json')) {
4413
+ const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
4414
+ const store = blockedStore(file);
4415
+ let settings;
4416
+ const read = store.read().then((s) => { settings = s; });
4417
+ ctx.on('tools/pre-execute', async (exec, next) => {
4418
+ const decision = await next();
4419
+ // A denied or cancelled call stays so; only an allowed call and one that would ask are checked.
4420
+ if ((decision?.kind !== 'allow' && decision?.kind !== 'ask') || (exec.name !== 'bash' && exec.name !== 'pwsh')) return decision;
4421
+ await read;
4422
+ if (!settings.on || settings.rules.length === 0) return decision;
4423
+ const hit = blockedHit(settings.rules, argsOf(exec.arguments)?.command, exec.name === 'pwsh');
4424
+ if (hit === undefined) return decision;
4425
+ const shown = shownCommand(hit.command);
4426
+ // A chat whose approval policy is `never` (Full access, a delegated child) cannot ask: dsh would reject the ask with no
4427
+ // reason, so the agent reads this one instead. The effective policy is the chat's own `approval/policy` event, else the
4428
+ // configured default (dsh-user-approval `effectivePolicy`; `never` when dsh starts with DSH_PERMISSION_MODE=danger-full-access).
4429
+ let never = false;
4430
+ try {
4431
+ const approval = get('approval'); const session = exec.agent?.session;
4432
+ const policy = typeof approval?.effectivePolicy === 'function' ? approval.effectivePolicy(session) : approval?.overrideOf?.(session) ?? approval?.config?.policy;
4433
+ never = policy === 'never';
4434
+ } catch { never = false; }
4435
+ if (hit.mode === 'block' || never) {
4436
+ const why = hit.mode === 'ask' ? ' (ask first, and this chat cannot ask: its approval policy is never)' : '';
4437
+ 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.` };
4438
+ }
4439
+ // dsh's approval card shows this reason. It must not start like an Auto review ask: Allow for this chat answers those.
4440
+ const own = `Composer Plus → Blocked commands: ${shown} matches "${hit.rule}" (ask first).`;
4441
+ return { kind: 'ask', reason: decision.kind === 'ask' && typeof decision.reason === 'string' && decision.reason !== '' ? `${own} ${decision.reason}` : own };
4442
+ });
4443
+ ctx.connection.fetch.register({
4444
+ path: BLOCKED_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
4445
+ fetch: async (request) => {
4446
+ await read;
4447
+ if (request.method === 'POST') {
4448
+ let body;
4449
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
4450
+ if (!plainObject(body)) return json({ error: 'bad request' }, 400);
4451
+ const keys = Object.keys(body).sort().join(',');
4452
+ const rules = keys === 'rules' ? parseRules(body.rules) : undefined;
4453
+ if (!(keys === 'on' && typeof body.on === 'boolean') && rules === undefined) return json({ error: 'bad request' }, 400);
4454
+ try { settings = await store.change((s) => (rules === undefined ? { ...s, on: body.on } : { ...s, rules })); } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
4455
+ }
4456
+ return json(settings);
4457
+ },
4458
+ });
4459
+ }
4460
+
4171
4461
  /** Worktree chats: a `.worktreeinclude` larger than this is refused (a cut pattern could name more than the user wrote). */
4172
4462
  export const INCLUDE_MAX = 64 * 1024;
4173
4463
  const WORKTREES_MAX = 20;
@@ -6125,3 +6415,748 @@ export function installBackup(ctx, home, { graceMs = AUTO_BACKUP_GRACE_MS, retry
6125
6415
  });
6126
6416
  return { settled: () => new Promise((resolve) => { const wait = () => (busy === null ? resolve() : setTimeout(wait, 10)); wait(); }), tick };
6127
6417
  }
6418
+
6419
+ /** Task board (Host half): a kanban board of the dsh projects for the user and the agents, after Kanzo's idea
6420
+ * (github.com/emanuele-em/kanzo, AGPL-3.0; no code of it is used here). One owner-only JSON file
6421
+ * `storages/dsh-composer-plus/board.json`: `{ v, next, nextComment, backlog, projects: [{path, slug}], tasks: [...] }`.
6422
+ * A task belongs to a project folder (a dsh project; a Branch per chat folder `<repo>/.worktrees/<name>` counts as its
6423
+ * repository). Task ids are one sequence for all projects; the key is `<slug>#<id>`. Each column has one order (a float
6424
+ * position) over all projects: a new task and a status change go to the bottom, a drop before a card goes between it and
6425
+ * the card above it. */
6426
+ export const BOARD_PATH = '/api/composer-plus.board';
6427
+ export const BOARD_TASKS_PATH = '/api/composer-plus.board-tasks';
6428
+ export const BOARD_STATUSES = ['backlog', 'todo', 'doing', 'review', 'done'];
6429
+ /** The board shows the newest this many done tasks of a view ("Show N older" loads the rest). */
6430
+ export const BOARD_DONE_SHOWN = 50;
6431
+ export const BOARD_LIMITS = { title: 300, description: 20000, tags: 20, tag: 40, comment: 10000, comments: 500, tasks: 10000, search: 30 };
6432
+ const BOARD_AGENT_DONE = 20; // board_list_tasks with a status shows at most this many done tasks (the newest)
6433
+ const BOARD_SHOWN_MAX = 2000;
6434
+ export const BOARD_SECTION = `# Task board
6435
+ This project has open tasks on the user's task board (Composer Plus → Task board). Tasks look like api#12. Keep the board current without being asked:
6436
+ - When you start a task from the board, move it to doing (board_next_task takes the top todo task and moves it).
6437
+ - Comment on what you did with board_add_comment: the result, the files, the checks and their outcome.
6438
+ - Move the task to review when you finish it or when you are blocked. Never move a task to done: the user does that.
6439
+ - Leave human tasks and backlog tasks to the user.`;
6440
+
6441
+ const boardStatuses = (data) => (data.backlog ? BOARD_STATUSES : BOARD_STATUSES.slice(1));
6442
+ /** One line: runs of white space become one space, the ends are trimmed. */
6443
+ const boardLine = (raw) => String(raw ?? '').replace(/\s+/g, ' ').trim();
6444
+ /** Comma-separated tags: each one trimmed, lower case, one space between words; no empty or repeated tag; first seen first. */
6445
+ export const boardTags = (raw) => {
6446
+ const list = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
6447
+ const out = [];
6448
+ for (const part of list) { const tag = boardLine(part).toLowerCase(); if (tag !== '' && !out.includes(tag)) out.push(tag); }
6449
+ if (out.length > BOARD_LIMITS.tags) throw refused(`a task has at most ${BOARD_LIMITS.tags} tags`, 400);
6450
+ if (out.some((tag) => tag.length > BOARD_LIMITS.tag)) throw refused(`a tag has at most ${BOARD_LIMITS.tag} characters`, 400);
6451
+ return out;
6452
+ };
6453
+ /** A short id for a project name: lower case a–z, 0–9 and "-", at most 24 characters; a taken one gets -2, -3, … */
6454
+ export const boardSlug = (name, taken) => {
6455
+ const base = String(name ?? '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24).replace(/-+$/, '') || 'project';
6456
+ if (!taken.has(base)) return base;
6457
+ for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
6458
+ };
6459
+ const BRANCH_DIR = /^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/].*)?$/;
6460
+ const trimSlash = (p) => (p.length > 1 ? p.replace(/[\\/]+$/, '') : p);
6461
+ /** The board project of a chat folder: a Branch per chat folder counts as its repository, then the nearest dsh project at or
6462
+ * above the folder; else the folder itself. */
6463
+ export function boardProjectOf(cwd, paths) {
6464
+ let root = trimSlash(String(cwd));
6465
+ const branch = BRANCH_DIR.exec(root);
6466
+ if (branch !== null) root = branch[1];
6467
+ let best;
6468
+ for (const p of paths) {
6469
+ const q = trimSlash(p);
6470
+ if ((root === q || root.startsWith(q.endsWith(sep) ? q : q + sep)) && (best === undefined || q.length > best.length)) best = q;
6471
+ }
6472
+ return best ?? root;
6473
+ }
6474
+ const emptyBoard = () => ({ v: 1, next: 1, nextComment: 1, backlog: false, projects: [], tasks: [] });
6475
+ const isTime = (v) => typeof v === 'string' && v.length <= 40 && !Number.isNaN(Date.parse(v));
6476
+ /** A saved board with every malformed entry left out. */
6477
+ function cleanBoard(raw) {
6478
+ if (!plainObject(raw)) throw new Error('the board file is not a JSON object');
6479
+ const data = emptyBoard();
6480
+ data.backlog = raw.backlog === true;
6481
+ const slugs = new Set();
6482
+ for (const p of Array.isArray(raw.projects) ? raw.projects : []) {
6483
+ if (!isText(p?.path, 4096) || !isAbsolute(p.path) || !/^[a-z0-9-]{1,32}$/.test(p?.slug ?? '') || slugs.has(p.slug) || data.projects.some((x) => x.path === p.path)) continue;
6484
+ slugs.add(p.slug); data.projects.push({ path: p.path, slug: p.slug });
6485
+ }
6486
+ const ids = new Set();
6487
+ for (const t of Array.isArray(raw.tasks) ? raw.tasks : []) {
6488
+ if (!Number.isSafeInteger(t?.id) || t.id < 1 || ids.has(t.id) || !data.projects.some((p) => p.path === t.path) || !isText(t.title, BOARD_LIMITS.title)
6489
+ || !BOARD_STATUSES.includes(t.status) || !Number.isFinite(t.position) || !isTime(t.created) || !isTime(t.updated)) continue;
6490
+ ids.add(t.id);
6491
+ const comments = (Array.isArray(t.comments) ? t.comments : []).filter((c) => Number.isSafeInteger(c?.id) && (c.by === 'user' || c.by === 'agent')
6492
+ && isText(c.body, BOARD_LIMITS.comment) && isTime(c.at) && (c.sessionId === undefined || isText(c.sessionId, 128)))
6493
+ .map((c) => ({ id: c.id, by: c.by, ...(c.sessionId === undefined ? {} : { sessionId: c.sessionId }), body: c.body, at: c.at }));
6494
+ data.tasks.push({ id: t.id, path: t.path, title: t.title, description: typeof t.description === 'string' ? t.description.slice(0, BOARD_LIMITS.description) : '',
6495
+ tags: Array.isArray(t.tags) ? t.tags.filter((x) => isText(x, BOARD_LIMITS.tag)).slice(0, BOARD_LIMITS.tags) : [], human: t.human === true,
6496
+ status: t.status, position: t.position, created: t.created, updated: t.updated, comments });
6497
+ }
6498
+ if (data.tasks.some((t) => t.status === 'backlog')) data.backlog = true;
6499
+ // A loop, not Math.max(...list): a spread of about 125k values throws RangeError (the board allows 10k tasks × 500 comments).
6500
+ data.next = data.tasks.reduce((n, t) => Math.max(n, t.id + 1), Math.max(1, Number.isSafeInteger(raw.next) ? raw.next : 1));
6501
+ data.nextComment = data.tasks.reduce((n, t) => t.comments.reduce((m, c) => Math.max(m, c.id + 1), n), Math.max(1, Number.isSafeInteger(raw.nextComment) ? raw.nextComment : 1));
6502
+ return data;
6503
+ }
6504
+
6505
+ /** The board file: every change runs alone on a copy and replaces the file atomically; the copy becomes the board only
6506
+ * after the write (all or none). A broken file is kept as `board.json.broken-<time>` and the board starts empty; a file
6507
+ * that cannot be read (other than missing) stops every change, so nothing overwrites it. */
6508
+ export function boardStore(file, { now = () => new Date() } = {}) {
6509
+ let data = emptyBoard();
6510
+ let rev = Date.now();
6511
+ const ready = (async () => {
6512
+ let text;
6513
+ try { text = await readFile(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return; throw error; }
6514
+ try { data = cleanBoard(JSON.parse(text)); } catch { await rename(file, `${file}.broken-${Date.now()}`); }
6515
+ })();
6516
+ let queue = ready.catch(() => {});
6517
+ const write = async (next) => {
6518
+ await mkdir(dirname(file), { recursive: true });
6519
+ const tmp = `${file}.${process.pid}.tmp`;
6520
+ await writeFile(tmp, JSON.stringify(next), { mode: 0o600 });
6521
+ await rename(tmp, file);
6522
+ };
6523
+ return {
6524
+ ready,
6525
+ /** The board now (do not change it). Empty until the file is read. */
6526
+ peek: () => data,
6527
+ rev: () => rev,
6528
+ /** `fn(draft, now)` changes a copy; it throws to change nothing. Answers what `fn` answers. */
6529
+ change(fn) {
6530
+ const done = queue.then(async () => {
6531
+ await ready;
6532
+ const draft = structuredClone(data);
6533
+ const result = fn(draft, now().toISOString());
6534
+ await write(draft);
6535
+ data = draft; rev += 1;
6536
+ return result;
6537
+ });
6538
+ queue = done.catch(() => {});
6539
+ return done;
6540
+ },
6541
+ };
6542
+ }
6543
+
6544
+ /** The projects of the board: dsh's projects (not Branch per chat folders) and every project that has tasks, A–Z, each
6545
+ * with its slug (saved, or the one it gets with its first task) and its counts. */
6546
+ export function boardProjects(data, registry = []) {
6547
+ const list = new Map();
6548
+ for (const w of registry) {
6549
+ if (!isText(w?.path, 4096) || !isAbsolute(w.path) || BRANCH_DIR.test(trimSlash(w.path))) continue;
6550
+ const path = trimSlash(w.path);
6551
+ if (!list.has(path)) list.set(path, { path, name: (typeof w.title === 'string' && w.title.trim()) || basename(path) || path });
6552
+ }
6553
+ for (const p of data.projects) if (!list.has(p.path)) list.set(p.path, { path: p.path, name: basename(p.path) || p.path });
6554
+ const taken = new Set(data.projects.map((p) => p.slug));
6555
+ const out = [...list.values()].sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
6556
+ for (const p of out) {
6557
+ const saved = data.projects.find((x) => x.path === p.path)?.slug;
6558
+ p.slug = saved ?? boardSlug(p.name, taken);
6559
+ taken.add(p.slug);
6560
+ const own = data.tasks.filter((t) => t.path === p.path);
6561
+ p.open = own.filter((t) => t.status !== 'done').length;
6562
+ p.total = own.length;
6563
+ }
6564
+ return out;
6565
+ }
6566
+ const byPosition = (a, b) => a.position - b.position || a.id - b.id;
6567
+ const slugMap = (projects) => new Map(projects.map((p) => [p.path, p.slug]));
6568
+ const keyOf = (t, slugs) => `${slugs.get(t.path) ?? 'task'}#${t.id}`;
6569
+ /** A card: the task without its comments (their count instead) and its description cut to a preview. */
6570
+ const boardCard = (t, slugs) => ({ id: t.id, key: keyOf(t, slugs), path: t.path, title: t.title, preview: t.description.slice(0, 240), tags: t.tags, human: t.human,
6571
+ status: t.status, position: t.position, created: t.created, updated: t.updated, comments: t.comments.length });
6572
+ /** One view of the board: `project` is a project path or 'all'. Done shows its newest BOARD_DONE_SHOWN unless `done`. */
6573
+ export function boardView(data, registry, { project = 'all', done = false } = {}) {
6574
+ const projects = boardProjects(data, registry);
6575
+ const slugs = slugMap(projects);
6576
+ const own = data.tasks.filter((t) => project === 'all' || t.path === project).sort(byPosition);
6577
+ const finished = own.filter((t) => t.status === 'done');
6578
+ const hiddenDone = done ? 0 : Math.max(0, finished.length - BOARD_DONE_SHOWN);
6579
+ const hidden = new Set(finished.slice(0, hiddenDone).map((t) => t.id));
6580
+ return { backlog: data.backlog, statuses: boardStatuses(data), projects, project, hiddenDone, tasks: own.filter((t) => !hidden.has(t.id)).map((t) => boardCard(t, slugs)) };
6581
+ }
6582
+ /** A task with its comments, for the detail panel. */
6583
+ export function boardTask(data, registry, id) {
6584
+ const t = data.tasks.find((x) => x.id === id);
6585
+ if (t === undefined) return undefined;
6586
+ const projects = boardProjects(data, registry);
6587
+ return { ...boardCard(t, slugMap(projects)), description: t.description, comments: t.comments, project: projects.find((p) => p.path === t.path)?.name ?? basename(t.path) };
6588
+ }
6589
+ /** Search every project: each word must be in the key, title, description or tags. Open tasks first, then the newest change. */
6590
+ export function boardSearch(data, registry, query) {
6591
+ const slugs = slugMap(boardProjects(data, registry));
6592
+ const words = boardLine(query).toLowerCase().split(' ').filter(Boolean).slice(0, 10);
6593
+ const hits = data.tasks.filter((t) => { const text = `${keyOf(t, slugs)} ${t.title} ${t.description} ${t.tags.join(' ')}`.toLowerCase(); return words.every((w) => text.includes(w)); });
6594
+ hits.sort((a, b) => (a.status === 'done') - (b.status === 'done') || Date.parse(b.updated) - Date.parse(a.updated) || b.id - a.id);
6595
+ return hits.slice(0, BOARD_LIMITS.search).map((t) => boardCard(t, slugs));
6596
+ }
6597
+ const utcTime = (iso) => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
6598
+ /** The board (one project or all) as Markdown or JSON, with every done task and every comment. */
6599
+ export function boardExport(data, registry, project, format, at = new Date().toISOString()) {
6600
+ const projects = boardProjects(data, registry).filter((p) => (project === 'all' ? p.total > 0 : p.path === project));
6601
+ const statuses = boardStatuses(data);
6602
+ const tasksOf = (p) => data.tasks.filter((t) => t.path === p.path).sort((a, b) => statuses.indexOf(a.status) - statuses.indexOf(b.status) || byPosition(a, b));
6603
+ if (format === 'json') {
6604
+ return JSON.stringify({ exported: at, statuses, projects: projects.map((p) => ({ name: p.name, slug: p.slug, path: p.path, open: p.open, total: p.total,
6605
+ tasks: tasksOf(p).map((t) => ({ key: `${p.slug}#${t.id}`, id: t.id, title: t.title, description: t.description, tags: t.tags, human: t.human, status: t.status,
6606
+ created: t.created, updated: t.updated, comments: t.comments })) })) }, null, 2);
6607
+ }
6608
+ const lines = ['# Task board', '', `Exported ${utcTime(at)}.`];
6609
+ for (const p of projects) {
6610
+ lines.push('', `## ${p.name} \`${p.slug}\``, '', `${p.open} open of ${p.total} task(s).`);
6611
+ for (const status of statuses) {
6612
+ const list = tasksOf(p).filter((t) => t.status === status);
6613
+ if (list.length === 0) continue;
6614
+ lines.push('', `### ${status}`);
6615
+ for (const t of list) {
6616
+ lines.push('', `#### ${p.slug}#${t.id} ${t.title}`, '', `Created ${utcTime(t.created)}, updated ${utcTime(t.updated)}.`);
6617
+ if (t.human) lines.push('Human task.');
6618
+ if (t.tags.length > 0) lines.push(`Tags: ${t.tags.join(', ')}.`);
6619
+ if (t.description !== '') lines.push('', t.description);
6620
+ if (t.comments.length > 0) {
6621
+ lines.push('', 'Comments:', '');
6622
+ for (const c of t.comments) lines.push(`- **${c.by === 'agent' ? 'agent' : 'you'}**, ${utcTime(c.at)}: ${c.body.replace(/\n/g, '\n ')}`);
6623
+ }
6624
+ }
6625
+ }
6626
+ }
6627
+ return `${lines.join('\n')}\n`;
6628
+ }
6629
+
6630
+ /** The changes of the board, on a draft (`boardStore.change`). Each throws `refused` to change nothing. */
6631
+ const bottomOf = (d, status) => Math.floor(Math.max(0, ...d.tasks.filter((t) => t.status === status).map((t) => t.position))) + 1;
6632
+ const findTask = (d, id) => { const t = d.tasks.find((x) => x.id === id); if (t === undefined) throw refused(`no task #${id}`, 404); return t; };
6633
+ const checkStatus = (d, status) => { if (!boardStatuses(d).includes(status)) throw refused(`unknown status "${String(status).slice(0, 40)}"; use one of: ${boardStatuses(d).join(', ')}`, 400); return status; };
6634
+ const checkTitle = (raw) => { const title = boardLine(raw); if (title === '') throw refused('a task needs a title', 400); if (title.length > BOARD_LIMITS.title) throw refused(`a title has at most ${BOARD_LIMITS.title} characters`, 400); return title; };
6635
+ const checkText = (raw) => { const text = String(raw ?? '').trim(); if (text.length > BOARD_LIMITS.description) throw refused(`a description has at most ${BOARD_LIMITS.description} characters`, 400); return text; };
6636
+ /** The project's saved slug; a project gets one with its first task. */
6637
+ function ensureProject(d, path, registry) {
6638
+ const saved = d.projects.find((p) => p.path === path);
6639
+ if (saved !== undefined) return saved.slug;
6640
+ const listed = boardProjects(d, registry).find((p) => p.path === path);
6641
+ const slug = listed?.slug ?? boardSlug(basename(path), new Set(d.projects.map((p) => p.slug)));
6642
+ d.projects.push({ path, slug });
6643
+ return slug;
6644
+ }
6645
+ export const boardOps = {
6646
+ create(d, now, { path, title, description = '', tags = [], human = false, status = 'todo' }, registry) {
6647
+ if (d.tasks.length >= BOARD_LIMITS.tasks) throw refused(`the board keeps at most ${BOARD_LIMITS.tasks} tasks; delete old done tasks first`);
6648
+ const task = { id: d.next, path, title: checkTitle(title), description: checkText(description), tags: boardTags(tags), human: human === true,
6649
+ status: checkStatus(d, status), position: 0, created: now, updated: now, comments: [] };
6650
+ task.position = bottomOf(d, task.status);
6651
+ ensureProject(d, path, registry);
6652
+ d.next += 1; d.tasks.push(task);
6653
+ return task.id;
6654
+ },
6655
+ /** `was`: the values that the editor started from; a field that changed meanwhile (an agent, another window) refuses the
6656
+ * whole change with 409. */
6657
+ update(d, now, id, patch, was = {}, registry = []) {
6658
+ const t = findTask(d, id);
6659
+ const same = { title: (v) => t.title === boardLine(v), description: (v) => t.description === String(v ?? '').trim(), tags: (v) => t.tags.join(',') === boardTags(v).join(',') };
6660
+ for (const [field, value] of Object.entries(was)) if (same[field] !== undefined && !same[field](value)) throw refused(`the ${field} changed meanwhile`, 409);
6661
+ let changed = false;
6662
+ if (patch.title !== undefined) { const v = checkTitle(patch.title); changed ||= v !== t.title; t.title = v; }
6663
+ if (patch.description !== undefined) { const v = checkText(patch.description); changed ||= v !== t.description; t.description = v; }
6664
+ if (patch.tags !== undefined) { const v = boardTags(patch.tags); changed ||= v.join(',') !== t.tags.join(','); t.tags = v; }
6665
+ if (patch.human !== undefined) { changed ||= t.human !== (patch.human === true); t.human = patch.human === true; }
6666
+ if (patch.path !== undefined && patch.path !== t.path) { ensureProject(d, patch.path, registry); t.path = patch.path; changed = true; }
6667
+ if (patch.status !== undefined && checkStatus(d, patch.status) !== t.status) { t.position = bottomOf(d, patch.status); t.status = patch.status; changed = true; }
6668
+ if (changed) t.updated = now;
6669
+ return t.id;
6670
+ },
6671
+ /** A drag: to the bottom of `status`, or just above the task `before` in that column. */
6672
+ move(d, now, id, status, before) {
6673
+ const t = findTask(d, id);
6674
+ checkStatus(d, status);
6675
+ const column = () => d.tasks.filter((x) => x.status === status && x.id !== id).sort(byPosition);
6676
+ const target = before === undefined ? undefined : column().find((x) => x.id === before);
6677
+ if (t.status !== status) t.updated = now;
6678
+ t.status = status;
6679
+ if (target === undefined) { t.position = bottomOf({ tasks: column() }, status); return t.id; }
6680
+ const place = () => { const list = column(); const i = list.indexOf(target); return i <= 0 ? [undefined, target] : [list[i - 1], target]; };
6681
+ let [above] = place();
6682
+ if (above !== undefined && target.position - above.position < 1e-9) { column().forEach((x, i) => { x.position = i + 1; }); [above] = place(); }
6683
+ t.position = above === undefined ? target.position - 1 : (above.position + target.position) / 2;
6684
+ return t.id;
6685
+ },
6686
+ comment(d, now, id, { body, by, sessionId }) {
6687
+ const t = findTask(d, id);
6688
+ const text = String(body ?? '').trim();
6689
+ if (text === '') throw refused('a comment needs text', 400);
6690
+ if (text.length > BOARD_LIMITS.comment) throw refused(`a comment has at most ${BOARD_LIMITS.comment} characters`, 400);
6691
+ if (t.comments.length >= BOARD_LIMITS.comments) throw refused(`a task keeps at most ${BOARD_LIMITS.comments} comments`);
6692
+ t.comments.push({ id: d.nextComment, by, ...(isText(sessionId, 128) ? { sessionId } : {}), body: text, at: now });
6693
+ d.nextComment += 1;
6694
+ return t.id;
6695
+ },
6696
+ uncomment(d, id, commentId) {
6697
+ const t = findTask(d, id);
6698
+ const at = t.comments.findIndex((c) => c.id === commentId);
6699
+ if (at < 0) throw refused('no such comment', 404);
6700
+ t.comments.splice(at, 1);
6701
+ return t.id;
6702
+ },
6703
+ remove(d, id) { const t = findTask(d, id); d.tasks.splice(d.tasks.indexOf(t), 1); return t; },
6704
+ /** The top todo task that is not for a person, moved to the bottom of doing; undefined when there is none. */
6705
+ next(d, now, path) {
6706
+ const t = d.tasks.filter((x) => x.status === 'todo' && !x.human && (path === 'all' || x.path === path)).sort(byPosition)[0];
6707
+ if (t === undefined) return undefined;
6708
+ t.position = bottomOf(d, 'doing'); t.status = 'doing'; t.updated = now;
6709
+ return t.id;
6710
+ },
6711
+ };
6712
+
6713
+ /** Agents' text: `api#12 [todo] Title {tag, tag} (2 comments)` and a task in full. */
6714
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
6715
+ const boardTaskLine = (t, slugs) => `${keyOf(t, slugs)} [${t.status}] ${t.title}${t.human ? ' (human)' : ''}${t.tags.length > 0 ? ` {${t.tags.join(', ')}}` : ''}${t.comments.length > 0 ? ` (${plural(t.comments.length, 'comment')})` : ''}`;
6716
+ function boardTaskText(t, slugs) {
6717
+ const lines = [`${keyOf(t, slugs)} ${t.title}${t.tags.length > 0 ? ` {${t.tags.join(', ')}}` : ''}`,
6718
+ `status: ${t.status}${t.human ? ', human: for the user, not for agents' : ''}, created ${utcTime(t.created)}${t.updated !== t.created ? `, updated ${utcTime(t.updated)}` : ''}`];
6719
+ if (t.description !== '') lines.push('', t.description);
6720
+ if (t.comments.length > 0) lines.push('', `Comments (${t.comments.length}):`, ...t.comments.map((c) => `- ${c.by === 'agent' ? 'agent' : 'user'}, ${utcTime(c.at)}: ${c.body}`));
6721
+ return lines.join('\n');
6722
+ }
6723
+ /** `api#12`, `#12`, `12` or 12; a slug that is not the task's project finds nothing. */
6724
+ export function boardKey(raw, data, slugs) {
6725
+ const m = /^\s*(?:([a-z0-9-]{1,32})?#)?(\d{1,15})\s*$/i.exec(String(raw ?? ''));
6726
+ if (m === null) throw refused(`"${String(raw).slice(0, 40)}" is not a task: use a key like api#12`, 400);
6727
+ const t = findTask(data, Number(m[2]));
6728
+ if (m[1] !== undefined && slugs.get(t.path) !== m[1].toLowerCase()) throw refused(`no task ${raw}`, 404);
6729
+ return t.id;
6730
+ }
6731
+
6732
+ /** Task board (Host half): the switch route, the board route, and while the switch is on the tools and the prompt section. */
6733
+ export function installBoard(ctx, home, dir = join(home, 'storages', 'dsh-composer-plus')) {
6734
+ const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
6735
+ const store = boardStore(join(dir, 'board.json'));
6736
+ const sw = mobileSwitch(join(dir, 'board-switch.json'), true);
6737
+ const registry = () => { try { return get('workspaceRegistry')?.list?.() ?? []; } catch { return []; } };
6738
+ const listing = () => boardProjects(store.peek(), registry());
6739
+ let on = true;
6740
+ let fiber;
6741
+ const shown = new Set(); // chats whose prompt has the section: it stays (the prompt prefix stays the same)
6742
+ const folderOf = (agent) => {
6743
+ let cwd;
6744
+ try { cwd = get('workingDirectory')?.get?.(agent?.session); } catch { cwd = undefined; }
6745
+ cwd ??= agent?.session?.header?.cwd;
6746
+ return isText(cwd, 4096) && isAbsolute(cwd) ? cwd : undefined;
6747
+ };
6748
+ const sectionFor = (agent) => {
6749
+ if (!on || !isText(agent?.id)) return '';
6750
+ if (shown.has(agent.id)) return BOARD_SECTION;
6751
+ const cwd = folderOf(agent);
6752
+ if (cwd === undefined) return '';
6753
+ const path = boardProjectOf(cwd, listing().map((p) => p.path));
6754
+ if (!store.peek().tasks.some((t) => t.path === path && ['todo', 'doing', 'review'].includes(t.status))) return '';
6755
+ shown.add(agent.id);
6756
+ if (shown.size > BOARD_SHOWN_MAX) shown.delete(shown.values().next().value);
6757
+ return BOARD_SECTION;
6758
+ };
6759
+ /** The project that a tool names (`project`: a slug, a name or "all"), else the calling chat's project. */
6760
+ const projectArg = (exec, raw, allowAll) => {
6761
+ const projects = listing();
6762
+ const name = boardLine(raw).toLowerCase();
6763
+ if (name === 'all') { if (allowAll) return 'all'; throw refused('name one project, not "all"', 400); }
6764
+ if (name !== '') {
6765
+ const hit = projects.find((p) => p.slug === name) ?? projects.find((p) => p.name.toLowerCase() === name);
6766
+ if (hit === undefined) throw refused(`no project "${String(raw).slice(0, 60)}"; projects: ${projects.map((p) => p.slug).join(', ') || 'none'}`, 404);
6767
+ return hit.path;
6768
+ }
6769
+ const cwd = folderOf(exec?.agent);
6770
+ if (cwd === undefined) throw refused('this chat has no project folder: pass `project` (see board_list_projects)', 400);
6771
+ return boardProjectOf(cwd, projects.map((p) => p.path));
6772
+ };
6773
+ const slugs = () => slugMap(listing());
6774
+ const keyParam = { type: 'string', description: 'The task, like api#12' };
6775
+ const projectParam = { type: 'string', description: 'A project slug or name, or "all". Default: the project of this chat' };
6776
+ const tools = [
6777
+ { name: 'board_list_projects', description: 'List the projects of the task board with their slugs and open task counts.',
6778
+ parameters: { type: 'object', properties: {} }, output: textOutput,
6779
+ async execute(_args, exec) {
6780
+ await store.ready;
6781
+ const projects = listing();
6782
+ let here; try { here = projectArg(exec, '', false); } catch { here = undefined; }
6783
+ return projects.length === 0 ? 'No projects.' : projects.map((p) => `${p.slug}${p.slug !== p.name ? ` "${p.name}"` : ''}: ${p.open} open of ${p.total}${p.path === here ? ' <- this chat' : ''}`).join('\n');
6784
+ } },
6785
+ { name: 'board_list_tasks', description: 'List the tasks of the task board in board order. Without status, done and human tasks are left out.',
6786
+ parameters: { type: 'object', properties: { project: projectParam, status: { type: 'string', description: 'One column (backlog, todo, doing, review, done), or "all"' } } }, output: textOutput,
6787
+ async execute(args, exec) {
6788
+ await store.ready;
6789
+ const d = store.peek(); const path = projectArg(exec, args?.project, true); const s = slugs();
6790
+ const status = boardLine(args?.status).toLowerCase();
6791
+ if (status !== '' && status !== 'all') checkStatus(d, status);
6792
+ const statuses = boardStatuses(d);
6793
+ const own = d.tasks.filter((t) => (path === 'all' || t.path === path) && (status === '' || status === 'all' || t.status === status))
6794
+ .sort((a, b) => statuses.indexOf(a.status) - statuses.indexOf(b.status) || byPosition(a, b));
6795
+ const human = own.filter((t) => t.human).length;
6796
+ const done = own.filter((t) => t.status === 'done');
6797
+ const shownDone = status === '' ? [] : done.slice(-BOARD_AGENT_DONE);
6798
+ const list = own.filter((t) => !t.human && (t.status !== 'done' || shownDone.includes(t)));
6799
+ const lines = list.map((t) => boardTaskLine(t, s));
6800
+ if (lines.length === 0) lines.push(`No tasks in ${path === 'all' ? 'any project' : s.get(path) ?? basename(path)}.`);
6801
+ if (human > 0) lines.push(`(${plural(human, 'human task')} not shown: for the user, not for agents)`);
6802
+ const left = done.filter((t) => !t.human).length - shownDone.filter((t) => !t.human).length;
6803
+ if (left > 0) lines.push(status === '' ? `(${left} done not shown; pass status "done" or "all")` : `(${left} older done not shown)`);
6804
+ return lines.join('\n');
6805
+ } },
6806
+ { name: 'board_get_task', description: 'Show one task of the task board with its description and comments.',
6807
+ parameters: { type: 'object', properties: { id: keyParam }, required: ['id'] }, output: textOutput,
6808
+ async execute(args) { await store.ready; const d = store.peek(); const s = slugs(); return boardTaskText(findTask(d, boardKey(args?.id, d, s)), s); } },
6809
+ { name: 'board_create_task', description: 'Add a task to the task board, at the bottom of todo unless you pass status.',
6810
+ parameters: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string', description: 'Markdown' }, project: { type: 'string', description: 'A project slug or name. Default: the project of this chat' },
6811
+ tags: { type: 'string', description: 'Comma-separated' }, status: { type: 'string', description: 'A column; default todo' }, human: { type: 'boolean', description: 'For the user, not for agents' } }, required: ['title'] }, output: textOutput,
6812
+ async execute(args, exec) {
6813
+ await store.ready;
6814
+ const path = projectArg(exec, args?.project, false);
6815
+ const id = await store.change((d, now) => boardOps.create(d, now, { path, title: args?.title, description: args?.description, tags: args?.tags, human: args?.human, status: boardLine(args?.status ?? 'todo').toLowerCase() || 'todo' }, registry()));
6816
+ const t = store.peek().tasks.find((x) => x.id === id);
6817
+ return `Created ${keyOf(t, slugs())} in ${t.status}: ${t.title}`;
6818
+ } },
6819
+ { name: 'board_update_task', description: 'Change the fields that you pass of one task of the task board. A new status puts it at the bottom of that column.',
6820
+ parameters: { type: 'object', properties: { id: keyParam, title: { type: 'string' }, description: { type: 'string', description: 'Markdown' }, status: { type: 'string', description: 'A column, like review' },
6821
+ tags: { type: 'string', description: 'Comma-separated; "" removes all' }, human: { type: 'boolean' } }, required: ['id'] }, output: textOutput,
6822
+ async execute(args) {
6823
+ await store.ready;
6824
+ let before;
6825
+ const id = await store.change((d, now) => {
6826
+ const tid = boardKey(args?.id, d, slugMap(boardProjects(d, registry())));
6827
+ before = d.tasks.find((x) => x.id === tid).status;
6828
+ return boardOps.update(d, now, tid, { title: args?.title, description: args?.description, tags: args?.tags, human: typeof args?.human === 'boolean' ? args.human : undefined,
6829
+ status: args?.status === undefined ? undefined : boardLine(args.status).toLowerCase() });
6830
+ });
6831
+ const t = store.peek().tasks.find((x) => x.id === id);
6832
+ return `Updated ${keyOf(t, slugs())}${before !== t.status ? `, ${before} -> ${t.status}` : ''}: ${t.title}`;
6833
+ } },
6834
+ { name: 'board_add_comment', description: 'Comment on a task of the task board, for example to report what you did.',
6835
+ parameters: { type: 'object', properties: { id: keyParam, body: { type: 'string', description: 'Markdown' } }, required: ['id', 'body'] }, output: textOutput,
6836
+ async execute(args, exec) {
6837
+ await store.ready;
6838
+ const id = await store.change((d, now) => boardOps.comment(d, now, boardKey(args?.id, d, slugMap(boardProjects(d, registry()))), { body: args?.body, by: 'agent', sessionId: exec?.agent?.id }));
6839
+ return `Comment added to ${keyOf(store.peek().tasks.find((x) => x.id === id), slugs())}.`;
6840
+ } },
6841
+ { name: 'board_next_task', description: 'Take the top todo task of the task board that is not for a person, move it to doing and show it.',
6842
+ parameters: { type: 'object', properties: { project: projectParam } }, output: textOutput,
6843
+ async execute(args, exec) {
6844
+ await store.ready;
6845
+ const path = projectArg(exec, args?.project, true);
6846
+ const id = await store.change((d, now) => { const tid = boardOps.next(d, now, path); if (tid === undefined) throw Object.assign(new Error('none'), { none: true }); return tid; })
6847
+ .catch((error) => { if (error?.none) return undefined; throw error; });
6848
+ const s = slugs();
6849
+ if (id === undefined) return `Nothing in todo for ${path === 'all' ? 'any project' : s.get(path) ?? basename(path)}.`;
6850
+ return `Moved to doing:\n${boardTaskText(store.peek().tasks.find((x) => x.id === id), s)}`;
6851
+ } },
6852
+ { name: 'board_delete_task', description: 'Delete a task of the task board with its comments. This cannot be undone.',
6853
+ parameters: { type: 'object', properties: { id: keyParam }, required: ['id'] }, output: textOutput,
6854
+ async execute(args) {
6855
+ await store.ready;
6856
+ const s = slugs();
6857
+ const t = await store.change((d) => boardOps.remove(d, boardKey(args?.id, d, s)));
6858
+ return `Deleted ${keyOf(t, s)}${t.comments.length > 0 ? ` and its ${plural(t.comments.length, 'comment')}` : ''}: ${t.title}`;
6859
+ } },
6860
+ ];
6861
+ const apply = (next) => {
6862
+ on = next;
6863
+ if (on && fiber === undefined && typeof ctx.inject === 'function') {
6864
+ fiber = ctx.inject(['tools', 'systemPrompt'], (sub) => {
6865
+ for (const tool of tools) sub.tools.register(tool);
6866
+ sub.systemPrompt.section({ name: 'composer-plus:board', order: 655, interpolate: false, text: (context) => sectionFor(context?.agent) });
6867
+ });
6868
+ } else if (!on) { fiber?.dispose?.(); fiber = undefined; shown.clear(); }
6869
+ };
6870
+ const ready = sw.read().then(apply, () => apply(true));
6871
+ ctx.effect?.(() => () => { on = false; fiber?.dispose?.(); fiber = undefined; }, 'dsh-composer-plus: task board');
6872
+ let switching = ready;
6873
+ ctx.connection.fetch.register({
6874
+ path: BOARD_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
6875
+ fetch: async (request) => {
6876
+ await ready;
6877
+ try {
6878
+ if (request.method === 'POST') {
6879
+ let body;
6880
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
6881
+ const key = plainObject(body) ? Object.keys(body).join() : '';
6882
+ if ((key !== 'on' && key !== 'backlog') || typeof body[key] !== 'boolean') return json({ error: 'bad request' }, 400);
6883
+ if (key === 'on') {
6884
+ const change = switching.then(async () => { await sw.write(body.on); apply(body.on); });
6885
+ switching = change.catch(() => {});
6886
+ await change;
6887
+ } else {
6888
+ await store.change((d) => {
6889
+ if (!body.backlog && d.tasks.some((t) => t.status === 'backlog')) throw refused(`move the ${plural(d.tasks.filter((t) => t.status === 'backlog').length, 'task')} out of Backlog first`);
6890
+ d.backlog = body.backlog;
6891
+ });
6892
+ }
6893
+ }
6894
+ await store.ready.catch(() => {});
6895
+ return json({ on, backlog: store.peek().backlog });
6896
+ } catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
6897
+ },
6898
+ });
6899
+ /** The board. GET: `?project=<path>|all[&done=all][&rev=N]` (a view; `{rev, same}` when nothing changed since rev),
6900
+ * `?sessionId=` (the view of that chat's project), `?task=<id>`, `?q=<words>`, `?export=markdown|json&project=`. POST
6901
+ * `{op, …}`: create, update, move, comment, uncomment, delete. While the switch is off: 409. */
6902
+ ctx.connection.fetch.register({
6903
+ path: BOARD_TASKS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
6904
+ fetch: async (request) => {
6905
+ await ready;
6906
+ if (!on) return json({ error: 'Task board is off' }, 409);
6907
+ try {
6908
+ await store.ready;
6909
+ const known = (path) => isText(path, 4096) && listing().some((p) => p.path === path);
6910
+ if (request.method === 'GET') {
6911
+ const q = new URL(request.url).searchParams;
6912
+ const rev = store.rev();
6913
+ if (q.has('task')) {
6914
+ const task = boardTask(store.peek(), registry(), Number(q.get('task')));
6915
+ return task === undefined ? json({ error: 'this task was deleted', rev }, 404) : json({ rev, task });
6916
+ }
6917
+ if (q.has('q')) return json({ rev, results: boardSearch(store.peek(), registry(), String(q.get('q')).slice(0, 200)) });
6918
+ let project = q.get('project') ?? 'all';
6919
+ if (q.has('sessionId')) {
6920
+ // The open chat's project; a chat without a folder, or a folder that is no board project, shows every project.
6921
+ const cwd = await chatFolder(ctx.sessionQuery, q.get('sessionId'), request.signal).catch(() => undefined);
6922
+ project = cwd === undefined ? 'all' : boardProjectOf(cwd, listing().map((p) => p.path));
6923
+ if (!known(project)) project = 'all';
6924
+ } else if (project !== 'all' && !known(project)) return json({ error: 'unknown project' }, 404);
6925
+ if (q.has('export')) {
6926
+ const format = q.get('export') === 'json' ? 'json' : 'markdown';
6927
+ const name = project === 'all' ? 'task-board' : `task-board-${listing().find((p) => p.path === project)?.slug ?? 'project'}`;
6928
+ return json({ name: `${name}.${format === 'json' ? 'json' : 'md'}`, text: boardExport(store.peek(), registry(), project, format) });
6929
+ }
6930
+ if (q.get('rev') === String(rev)) return json({ rev, same: true });
6931
+ return json({ rev, ...boardView(store.peek(), registry(), { project, done: q.get('done') === 'all' }) });
6932
+ }
6933
+ let body;
6934
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
6935
+ if (!plainObject(body)) return json({ error: 'bad request' }, 400);
6936
+ const id = Number.isSafeInteger(body.id) && body.id > 0 ? body.id : undefined;
6937
+ const text = (v, max) => v === undefined || (typeof v === 'string' && v.length <= max);
6938
+ const fieldsOk = text(body.title, 1000) && text(body.description, BOARD_LIMITS.description + 1000) && (body.tags === undefined || typeof body.tags === 'string' || (Array.isArray(body.tags) && body.tags.every((x) => typeof x === 'string')))
6939
+ && (body.human === undefined || typeof body.human === 'boolean') && text(body.status, 20);
6940
+ let result;
6941
+ switch (body.op) {
6942
+ case 'create':
6943
+ if (!fieldsOk || !known(body.project) || typeof body.title !== 'string') return json({ error: 'bad request' }, 400);
6944
+ result = await store.change((d, now) => boardOps.create(d, now, { path: body.project, title: body.title, description: body.description, tags: body.tags, human: body.human, status: body.status ?? 'todo' }, registry()));
6945
+ break;
6946
+ case 'update': {
6947
+ const was = body.was ?? {};
6948
+ if (id === undefined || !fieldsOk || !plainObject(was) || !Object.entries(was).every(([k, v]) => ['title', 'description', 'tags'].includes(k) && typeof v === 'string')
6949
+ || (body.project !== undefined && !known(body.project))) return json({ error: 'bad request' }, 400);
6950
+ result = await store.change((d, now) => boardOps.update(d, now, id, { title: body.title, description: body.description, tags: body.tags, human: body.human, status: body.status, path: body.project }, was, registry()));
6951
+ break;
6952
+ }
6953
+ case 'move':
6954
+ if (id === undefined || typeof body.status !== 'string' || (body.before !== undefined && !Number.isSafeInteger(body.before))) return json({ error: 'bad request' }, 400);
6955
+ result = await store.change((d, now) => boardOps.move(d, now, id, body.status, body.before));
6956
+ break;
6957
+ case 'comment':
6958
+ if (id === undefined || typeof body.body !== 'string' || body.body.length > BOARD_LIMITS.comment + 1000) return json({ error: 'bad request' }, 400);
6959
+ result = await store.change((d, now) => boardOps.comment(d, now, id, { body: body.body, by: 'user' }));
6960
+ break;
6961
+ case 'uncomment':
6962
+ if (id === undefined || !Number.isSafeInteger(body.comment)) return json({ error: 'bad request' }, 400);
6963
+ result = await store.change((d) => boardOps.uncomment(d, id, body.comment));
6964
+ break;
6965
+ case 'delete':
6966
+ if (id === undefined) return json({ error: 'bad request' }, 400);
6967
+ await store.change((d) => boardOps.remove(d, id));
6968
+ return json({ rev: store.rev(), deleted: id });
6969
+ default: return json({ error: 'bad request' }, 400);
6970
+ }
6971
+ return json({ rev: store.rev(), task: boardTask(store.peek(), registry(), result) });
6972
+ } catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
6973
+ },
6974
+ });
6975
+ return { store, ready, tools, sectionFor };
6976
+ }
6977
+
6978
+ /* Planned steps, after HarnessMM (arXiv 2610.32813, "guarded decision reuse"): the model plans its next commands with the
6979
+ * exact arguments and a guard (`plan_next_steps`). When the newest tool results meet the guard of exactly one step, Composer
6980
+ * Plus answers the agent's next model request itself (dsh's `llm/stream` waterfall) with one assistant message: a short
6981
+ * reasoning block (only when the model's own planning message has one: DeepSeek's thinking mode refuses a history message
6982
+ * without it), a text line and the step's tool call. dsh then runs the call on its normal path (tools/pre-execute: approval,
6983
+ * sandbox, Protected files). Stateless: the plan is the tool call's arguments in the request history, so a restart, a fork,
6984
+ * a rollback or /compact needs no code of its own. No step, two ready steps, a failed guard or any user message → the model. */
6985
+ export const PLANNED_TOOL = 'plan_next_steps';
6986
+ export const PLANNED_LIMITS = { steps: 8, args: 8 * 1024 };
6987
+ export const PLANNED_STEP_TOOLS = ['bash', 'read', 'grep', 'glob'];
6988
+ const PLANNED_GUARDS = ['ok', 'always'];
6989
+ const PLANNED_ID = /^[a-z0-9-]{1,32}$/;
6990
+ /** Composer Plus's own tool-call ids: `cpgs-<8 hex of the plan call's id>-<step number>`. */
6991
+ const PLANNED_CALL = /^cpgs-([0-9a-f]{8})-([1-9]\d?)$/;
6992
+ const PLANNED_SECTION = `# Planned steps (Composer Plus)
6993
+ 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.
6994
+ - \`after\`: "root" (the other tool calls of that message) or the id of an earlier step.
6995
+ - \`when\`: "ok" (the predecessor succeeded; for bash: no \`[exit code: N]\` or other fail mark) or "always".
6996
+ - 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.
6997
+ 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.`;
6998
+
6999
+ /** The steps of one `plan_next_steps` call, checked; throws a plain reason (the tool's error text) for a bad plan. */
7000
+ export function parsePlan(args) {
7001
+ const fail = (message) => { throw new Error(message); };
7002
+ if (!plainObject(args) || !Array.isArray(args.steps)) fail('"steps" must be a list of steps');
7003
+ if (args.steps.length < 1 || args.steps.length > PLANNED_LIMITS.steps) fail(`a plan has 1 to ${PLANNED_LIMITS.steps} steps`);
7004
+ const ids = new Set();
7005
+ return args.steps.map((s, i) => {
7006
+ const at = `step ${i + 1}`;
7007
+ if (!plainObject(s)) fail(`${at} must be an object`);
7008
+ 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")`);
7009
+ if (ids.has(s.id)) fail(`${at}: the id "${s.id}" is used twice`);
7010
+ const after = s.after ?? 'root';
7011
+ if (after !== 'root' && !ids.has(after)) fail(`${at}: "after" must be "root" or the id of an earlier step`);
7012
+ const when = s.when ?? 'ok';
7013
+ if (!PLANNED_GUARDS.includes(when)) fail(`${at}: "when" must be "ok" or "always"`);
7014
+ if (!PLANNED_STEP_TOOLS.includes(s.tool)) fail(`${at}: "tool" must be one of ${PLANNED_STEP_TOOLS.join(', ')}`);
7015
+ if (!plainObject(s.arguments)) fail(`${at}: "arguments" must be an object`);
7016
+ 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)`);
7017
+ // dsh's bash tool needs a description; it is only the label in the chat.
7018
+ const args2 = s.tool === 'bash' && typeof s.arguments.description !== 'string' ? { description: `Planned step ${s.id}`, ...s.arguments } : s.arguments;
7019
+ if (JSON.stringify(args2).length > PLANNED_LIMITS.args) fail(`${at}: "arguments" are longer than ${PLANNED_LIMITS.args} characters`);
7020
+ ids.add(s.id);
7021
+ return { id: s.id, after, when, tool: s.tool, args: args2 };
7022
+ });
7023
+ }
7024
+
7025
+ /** One finished call passed: not an error, and for bash no fail mark of dsh in the last 8 lines (the Goal check rule). */
7026
+ const plannedOk = (tool, result) => result !== undefined && result.isError !== true
7027
+ && (tool !== 'bash' || !BASH_FAIL.test(textBlocks(result.content).split('\n').slice(-8).join('\n')));
7028
+ const planTag = (callId) => createHash('sha256').update(String(callId)).digest('hex').slice(0, 8);
7029
+
7030
+ /** The step to run now for a model request's `messages`, or undefined (then the model answers). `tools`: the names that the
7031
+ * request offers. Reads back from the end: tool results, dsh's runtime context, developer and system messages are neutral;
7032
+ * Composer Plus's own step messages are counted; the newest model message before them must hold an accepted plan. Any other
7033
+ * user message (the user, steer, Continue, /compact, a rollback marker) ends the plan. */
7034
+ export function nextPlannedStep(messages, tools) {
7035
+ if (!Array.isArray(messages)) return undefined;
7036
+ const results = new Map();
7037
+ const ran = [];
7038
+ let i = messages.length - 1;
7039
+ for (; i >= 0; i--) {
7040
+ const m = messages[i];
7041
+ if (m?.role === 'tool') { results.set(m.toolCallId ?? m.source?.callId, m); continue; }
7042
+ if (m?.role === 'developer' || m?.role === 'system') continue;
7043
+ if (m?.role === 'user') { if (m.source?.kind === 'runtime-context') continue; return undefined; }
7044
+ if (m?.role !== 'assistant') return undefined;
7045
+ const calls = (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type === 'tool-call');
7046
+ if (calls.length === 1 && PLANNED_CALL.test(calls[0].id)) { ran.push(calls[0]); continue; }
7047
+ break;
7048
+ }
7049
+ if (i < 0 || !Array.isArray(messages[i].content)) return undefined;
7050
+ const planned = messages[i];
7051
+ const calls = planned.content.filter((b) => b?.type === 'tool-call');
7052
+ const planCall = calls.find((c) => c.name === PLANNED_TOOL);
7053
+ if (planCall === undefined || !plannedOk(PLANNED_TOOL, results.get(planCall.id))) return undefined;
7054
+ let steps;
7055
+ try { steps = parsePlan(argsOf(planCall.arguments)); } catch { return undefined; }
7056
+ const tag = planTag(planCall.id);
7057
+ // `root`: the other calls of the planning message (the plan call alone when there are none); all of them have results.
7058
+ const rootCalls = calls.length > 1 ? calls.filter((c) => c !== planCall) : [planCall];
7059
+ if (!rootCalls.every((c) => results.has(c.id))) return undefined;
7060
+ const done = new Map([['root', rootCalls.every((c) => plannedOk(c.name, results.get(c.id)))]]);
7061
+ for (const call of ran) {
7062
+ const [, t, n] = call.id.match(PLANNED_CALL);
7063
+ const step = steps[Number(n) - 1];
7064
+ if (t !== tag || step === undefined || done.has(step.id) || !results.has(call.id)) return undefined;
7065
+ done.set(step.id, plannedOk(step.tool, results.get(call.id)));
7066
+ }
7067
+ const ready = steps.map((step, k) => ({ step, n: k + 1 })).filter(({ step }) => !done.has(step.id) && done.has(step.after)
7068
+ && (step.when === 'always' || done.get(step.after) === true) && (tools === undefined || tools.has(step.tool)));
7069
+ if (ready.length !== 1) return undefined;
7070
+ return { ...ready[0], tag, reasoning: planned.content.some((b) => b?.type === 'reasoning') };
7071
+ }
7072
+
7073
+ /** The chunks of the assistant message that runs `found` (the answer of nextPlannedStep). */
7074
+ export function plannedStepChunks(found) {
7075
+ const { step, n, tag, reasoning } = found;
7076
+ const id = `cpgs-${tag}-${n}`;
7077
+ const args = JSON.stringify(step.args);
7078
+ const blocks = [];
7079
+ 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.` });
7080
+ blocks.push({ type: 'text', text: `Composer Plus · Planned steps: ran \`${step.id}\` (after \`${step.after}\`, guard ${step.when}) without a model call.` });
7081
+ const chunks = [];
7082
+ blocks.forEach((b, index) => {
7083
+ chunks.push({ type: 'block-start', index, blockType: b.type });
7084
+ chunks.push({ type: b.type === 'text' ? 'text-delta' : 'reasoning-delta', index, text: b.text });
7085
+ chunks.push({ type: 'block-end', index, block: b });
7086
+ });
7087
+ const index = blocks.length;
7088
+ chunks.push({ type: 'block-start', index, blockType: 'tool-call' });
7089
+ chunks.push({ type: 'tool-call-delta', index, id, name: step.tool, argumentsDelta: args });
7090
+ chunks.push({ type: 'block-end', index, block: { type: 'tool-call', id, name: step.tool, arguments: args } });
7091
+ chunks.push({ type: 'finish', reason: { kind: 'tool-calls' } });
7092
+ return chunks;
7093
+ }
7094
+
7095
+ /** The `llm/stream` listener: an agent request of a chat (it has a session, no purpose such as compaction, and offers the
7096
+ * plan tool) with one ready step gets the step's message; every other request goes on to the model. */
7097
+ export function plannedStream(options, next) {
7098
+ let found;
7099
+ try {
7100
+ const tools = Array.isArray(options?.tools) ? new Set(options.tools.map((t) => t?.name)) : undefined;
7101
+ if (options?.sessionId !== undefined && options.purpose === undefined && tools?.has(PLANNED_TOOL)) found = nextPlannedStep(options.messages, tools);
7102
+ } catch { found = undefined; }
7103
+ if (found === undefined) return next();
7104
+ const chunks = plannedStepChunks(found);
7105
+ return (async function* () { yield* chunks; })();
7106
+ }
7107
+
7108
+ /** Planned steps (Host half): the switch route (off by default), and while it is on: the tool, the prompt section and the
7109
+ * `llm/stream` listener, all in one fiber that switching off disposes. */
7110
+ export function installPlannedSteps(ctx, home, file = join(home, 'storages', 'dsh-composer-plus', 'planned-steps.json')) {
7111
+ const sw = mobileSwitch(file);
7112
+ let on = false;
7113
+ let fiber;
7114
+ const tool = {
7115
+ name: PLANNED_TOOL,
7116
+ 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.`,
7117
+ parameters: { type: 'object', properties: {
7118
+ steps: { type: 'array', description: `1 to ${PLANNED_LIMITS.steps} steps, in order`, items: { type: 'object', properties: {
7119
+ id: { type: 'string', description: 'A short name: a-z, 0-9 and "-", such as "test"' },
7120
+ after: { type: 'string', description: '"root" (the default: the other tool calls of this message) or the id of an earlier step' },
7121
+ when: { type: 'string', enum: PLANNED_GUARDS, description: '"ok" (the default): only when the predecessor succeeded; "always": when it finished' },
7122
+ tool: { type: 'string', enum: PLANNED_STEP_TOOLS },
7123
+ arguments: { type: 'object', description: 'The complete, literal arguments of the tool call' },
7124
+ }, required: ['id', 'tool', 'arguments'] } },
7125
+ }, required: ['steps'] },
7126
+ output: textOutput,
7127
+ async execute(args) {
7128
+ const steps = parsePlan(args);
7129
+ 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.`;
7130
+ },
7131
+ };
7132
+ const apply = (next) => {
7133
+ on = next;
7134
+ if (on && fiber === undefined && typeof ctx.inject === 'function') {
7135
+ fiber = ctx.inject(['tools', 'systemPrompt'], (sub) => {
7136
+ sub.tools.register(tool);
7137
+ sub.systemPrompt.section({ name: 'composer-plus:planned-steps', order: 660, interpolate: false, text: () => PLANNED_SECTION });
7138
+ sub.on('llm/stream', plannedStream);
7139
+ });
7140
+ } else if (!on) { fiber?.dispose?.(); fiber = undefined; }
7141
+ };
7142
+ const ready = sw.read().then(apply, () => apply(false));
7143
+ ctx.effect?.(() => () => { on = false; fiber?.dispose?.(); fiber = undefined; }, 'dsh-composer-plus: planned steps');
7144
+ // One switch change at a time, in order: the saved file and the live state always end on the newest click.
7145
+ let switching = ready;
7146
+ ctx.connection.fetch.register({
7147
+ path: PLANNED_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
7148
+ fetch: async (request) => {
7149
+ await ready;
7150
+ if (request.method === 'POST') {
7151
+ let body;
7152
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
7153
+ if (!plainObject(body) || Object.keys(body).join() !== 'on' || typeof body.on !== 'boolean') return json({ error: 'bad request' }, 400);
7154
+ const change = switching.then(async () => { await sw.write(body.on); apply(body.on); });
7155
+ switching = change.catch(() => {});
7156
+ try { await change; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
7157
+ }
7158
+ return json({ on });
7159
+ },
7160
+ });
7161
+ return { ready, tool };
7162
+ }