dsh-composer-plus 1.18.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/index.js CHANGED
@@ -688,6 +688,19 @@ export const releaseNotes = (body) => {
688
688
  const text = body.replace(/\r\n?/g, '\n').split(/^## Install\b/m)[0].trim().slice(0, NOTES_MAX);
689
689
  return text === '' ? undefined : text;
690
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
+ };
691
704
  /** Latest GitHub release vs this copy. `managed` is false for a renamed or linked local copy, which pnpm can't upgrade. */
692
705
  export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
693
706
  const current = parseVersion(pkg.version);
@@ -1898,8 +1911,12 @@ export function apply(ctx) {
1898
1911
  ctx.connection.fetch.register({
1899
1912
  path: UPDATE_PATH, methods: ['GET'], requestBody: 'buffered',
1900
1913
  fetch: async (request) => {
1901
- // `?current=1`: only the version that this Host runs (What's new after an update); no network call.
1902
- if (new URL(request.url).searchParams.get('current') === '1') return json({ current: PACKAGE.version });
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
+ }
1903
1920
  try { return json(await checkUpdate(fetch, PACKAGE, request.signal)); } catch (error) { return json({ error: String(error?.message ?? error) }, 502); }
1904
1921
  },
1905
1922
  });
@@ -6416,124 +6433,76 @@ export function installBackup(ctx, home, { graceMs = AUTO_BACKUP_GRACE_MS, retry
6416
6433
  return { settled: () => new Promise((resolve) => { const wait = () => (busy === null ? resolve() : setTimeout(wait, 10)); wait(); }), tick };
6417
6434
  }
6418
6435
 
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. */
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. */
6426
6441
  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
- };
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.`;
6459
6448
  const BRANCH_DIR = /^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/].*)?$/;
6460
6449
  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 });
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;
6497
6466
  }
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();
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 = {};
6510
6475
  let rev = Date.now();
6511
6476
  const ready = (async () => {
6512
6477
  let text;
6513
6478
  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()}`); }
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(() => {}); }
6515
6487
  })();
6516
6488
  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
6489
  return {
6524
6490
  ready,
6525
- /** The board now (do not change it). Empty until the file is read. */
6526
- peek: () => data,
6527
6491
  rev: () => rev,
6528
- /** `fn(draft, now)` changes a copy; it throws to change nothing. Answers what `fn` answers. */
6492
+ list: () => chats,
6493
+ /** `fn(copy)` changes a copy of the record; the copy becomes the record only after the write. */
6529
6494
  change(fn) {
6530
6495
  const done = queue.then(async () => {
6531
6496
  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;
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;
6537
6506
  });
6538
6507
  queue = done.catch(() => {});
6539
6508
  return done;
@@ -6541,438 +6510,130 @@ export function boardStore(file, { now = () => new Date() } = {}) {
6541
6510
  };
6542
6511
  }
6543
6512
 
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;
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)));
6565
6519
  }
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);
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;
6607
6538
  }
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
- }
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 });
6626
6568
  }
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
- };
6569
+ return { sessionId, agent };
6570
+ }
6712
6571
 
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. */
6572
+ /** Task board (Host half): the switch route and, while it is on, the board's chat route. */
6733
6573
  export function installBoard(ctx, home, dir = join(home, 'storages', 'dsh-composer-plus')) {
6734
6574
  const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
6735
- const store = boardStore(join(dir, 'board.json'));
6575
+ const store = boardChatsStore(join(dir, 'board-chats.json'));
6736
6576
  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
6577
  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(); }
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); }
6869
6595
  };
6870
- const ready = sw.read().then(apply, () => apply(true));
6871
- ctx.effect?.(() => () => { on = false; fiber?.dispose?.(); fiber = undefined; }, 'dsh-composer-plus: task board');
6872
6596
  let switching = ready;
6873
6597
  ctx.connection.fetch.register({
6874
6598
  path: BOARD_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
6875
6599
  fetch: async (request) => {
6876
6600
  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); }
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 });
6897
6612
  },
6898
6613
  });
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. */
6614
+ /** GET `?rev=N`: `{rev, chats}` (`{rev, same}` when nothing changed since rev). POST `{project, prompt}`: start a chat. */
6902
6615
  ctx.connection.fetch.register({
6903
- path: BOARD_TASKS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
6616
+ path: BOARD_CHATS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
6904
6617
  fetch: async (request) => {
6905
6618
  await ready;
6906
6619
  if (!on) return json({ error: 'Task board is off' }, 409);
6907
6620
  try {
6908
6621
  await store.ready;
6909
- const known = (path) => isText(path, 4096) && listing().some((p) => p.path === path);
6910
6622
  if (request.method === 'GET') {
6911
- const q = new URL(request.url).searchParams;
6912
6623
  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' }) });
6624
+ if (new URL(request.url).searchParams.get('rev') === String(rev)) return json({ rev, same: true });
6625
+ return json({ rev, chats: store.list() });
6932
6626
  }
6933
6627
  let body;
6934
6628
  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) });
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 });
6972
6633
  } catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
6973
6634
  },
6974
6635
  });
6975
- return { store, ready, tools, sectionFor };
6636
+ return { store, ready, tag, isOn: () => on };
6976
6637
  }
6977
6638
 
6978
6639
  /* Planned steps, after HarnessMM (arXiv 2610.32813, "guarded decision reuse"): the model plans its next commands with the