diff --git a/electron-builder.yml b/electron-builder.yml index e91c78f..829d41d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -27,6 +27,9 @@ asarUnpack: # level deeper than the entry point: mcp-bots.js unpacked fine and still died on # `Cannot find module './bots.js'`. Pinned by test/asar-helpers.test.js. - "bots.js" + # One level deeper the same trap: mcp-task-manager.js requires the task-text limit, + # so that module has to sit outside the archive with it. + - "task-text-limit.js" files: - "**/*" diff --git a/mcp-task-manager.js b/mcp-task-manager.js index a486b8e..1adf933 100644 --- a/mcp-task-manager.js +++ b/mcp-task-manager.js @@ -13,6 +13,9 @@ const http = require('http'); const { StringDecoder } = require('string_decoder'); +// Same limit the HTTP API enforces; advertising a smaller one here makes the model +// shorten a task for no reason. +const { MAX_TASK_TEXT } = require('./task-text-limit.js'); const SERVER_URL = process.env.TASK_MANAGER_SERVER_URL || 'http://127.0.0.1:3000'; const TASK_ID = process.env.TASK_MANAGER_TASK_ID || ''; @@ -42,7 +45,7 @@ const TOOLS = [ type: 'object', properties: { title: { type: 'string', description: 'Short task title (max 200 chars)' }, - description: { type: 'string', description: 'Detailed task description (max 2000 chars)' }, + description: { type: 'string', description: `Detailed task description (max ${MAX_TASK_TEXT} chars)` }, status: { type: 'string', enum: ['backlog', 'todo', 'done'], description: 'todo = queue it for execution now (default). backlog = add to the board only, nothing runs. done = an already-finished item, e.g. a "- [x]" line from a plan you are importing.' }, context: { description: 'Curated context to pass to the child task. Include ONLY what the child needs — issue details, code snippets, error messages, etc. Can be a string or a JSON object. The child reads this via get_current_task.', diff --git a/package.json b/package.json index 1327428..68557e5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node server.js", "dev": "node --watch server.js", - "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/claude-firstrun.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/agent-catalog.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/session-expiry.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/mac-signing.test.js && node test/release-mac.test.js && node test/task-workers.test.js && node test/git-clone.test.js && node test/responsive-ui.test.js && node test/kanban-delegation.test.js && node test/kanban-group-button.test.js && node test/terminal-keys.test.js && node test/electron-navigation.test.js && node test/cli-runtime.test.js && node test/worktree-safety.test.js && node test/auth-boundary.test.js && node test/cli-process-tree.test.js && node test/schedule-actions.test.js && node test/install-hooks.test.js && node test/frontend-recovery.test.js && node test/db-revisions.test.js && node test/frontend-refresh-races.test.js && node test/release-gate.test.js && node test/focused-workspace.test.js && node test/secondary-navigation.test.js && node test/section-controls-ux.test.js && node test/settings-ux.test.js && node test/team-forms-ux.test.js && node test/page-forms-ux.test.js && node test/sidebar-ux.test.js", + "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/claude-firstrun.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/agent-catalog.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/session-expiry.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/mac-signing.test.js && node test/release-mac.test.js && node test/task-workers.test.js && node test/git-clone.test.js && node test/responsive-ui.test.js && node test/kanban-delegation.test.js && node test/kanban-group-button.test.js && node test/terminal-keys.test.js && node test/electron-navigation.test.js && node test/cli-runtime.test.js && node test/worktree-safety.test.js && node test/auth-boundary.test.js && node test/cli-process-tree.test.js && node test/schedule-actions.test.js && node test/install-hooks.test.js && node test/frontend-recovery.test.js && node test/db-revisions.test.js && node test/frontend-refresh-races.test.js && node test/release-gate.test.js && node test/focused-workspace.test.js && node test/secondary-navigation.test.js && node test/section-controls-ux.test.js && node test/settings-ux.test.js && node test/team-forms-ux.test.js && node test/page-forms-ux.test.js && node test/sidebar-ux.test.js && node test/task-text-limit.test.js", "postinstall": "node scripts/install-hooks.js", "release": "node scripts/release.js", "release:mac": "node scripts/release-mac.js", diff --git a/public/kanban.html b/public/kanban.html index 9563df2..8dd2993 100644 --- a/public/kanban.html +++ b/public/kanban.html @@ -465,6 +465,8 @@ .modal-body .sc-sep { display:none; } .modal-body .sc-btn { min-height:32px; } .modal-body :is(input,select,textarea,button):focus-visible { outline:2px solid var(--accent); outline-offset:3px; } +.inp-hint{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.4} +.inp-hint.over{color:var(--red)} .modal-body .inp-hint,.modal-body .sched-hint { line-height:1.6; margin-top:6px; } @media(max-width:600px){ .modal-body .grid2 { grid-template-columns:1fr; } @@ -1030,6 +1032,19 @@ document.documentElement.lang = lang; document.documentElement.dir = (lang === 'he') ? 'rtl' : 'ltr'; +// ─── Task text limit ─────────────────────────────────────────────────────── +// The number server.js stores and mcp-task-manager.js advertises (task-text-limit.js). +// A maxlength attribute cuts a paste without a word, so a field that holds prose +// also carries a counter: it reaches MAX_TASK_TEXT exactly where the text was cut. +const MAX_TASK_TEXT = 20000; +function updTextCount(id, outId) { + const el = $i(id), out = $i(outId); + if (!el || !out) return; + const n = el.value.length; + out.textContent = n + ' / ' + MAX_TASK_TEXT; + out.classList.toggle('over', n >= MAX_TASK_TEXT); +} + // Declarative i18n — same attribute contract as index.html: the markup carries the // key, this pass fills in text/attributes for the active language. Every user-facing // literal in the markup must be annotated; test/i18n-completeness.test.js enforces it. @@ -1952,7 +1967,7 @@
- +
`; $i('modalFt').innerHTML=` @@ -1973,7 +1988,7 @@ 1.
- +
@@ -1990,7 +2005,7 @@ ${i+1}.
- +
`:` @@ -2069,7 +2084,7 @@ ${count+1}.
- +
`; container.appendChild(div); @@ -2224,7 +2239,7 @@
- +
`; $i('modalFt').innerHTML=editable?` @@ -2457,10 +2472,12 @@
- + +
${(tk.description||'').length} / ${MAX_TASK_TEXT}
- + +
${(tk.notes||'').length} / ${MAX_TASK_TEXT}
diff --git a/public/schedule.html b/public/schedule.html index 63d443a..5573fce 100644 --- a/public/schedule.html +++ b/public/schedule.html @@ -153,7 +153,7 @@ /* ─── Card body ─── */ .agcard-body{flex:1;min-width:0} .agcard-title{font-size:14px;font-weight:600;color:var(--text);margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.1px} -.agcard-desc{font-size:12px;color:var(--muted);margin-bottom:7px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;line-height:1.5} +.agcard-desc{font-size:12px;color:var(--muted);margin-bottom:7px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;line-height:1.5} .agcard-badges{display:flex;flex-wrap:wrap;gap:4px;align-items:center} /* ─── Badges ─── */ @@ -232,6 +232,7 @@ .inp[type="date"]{color-scheme:dark} .inp[type="date"]::-webkit-calendar-picker-indicator{filter:invert(.5) sepia(1) saturate(5) hue-rotate(200deg);cursor:pointer;opacity:.6} .inp-hint{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.4} +.inp-hint.over{color:var(--red)} .sel{width:100%;background:var(--s2);border:1px solid var(--border);border-radius:var(--r-sm);color:var(--text);font-size:13px;padding:8px 12px;font-family:inherit;cursor:pointer;transition:border-color .15s;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%237a8baa' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center} .sel:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,106,239,.12)} .grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px} @@ -585,6 +586,19 @@ let lang = localStorage.getItem('lang') || 'en'; document.documentElement.lang = lang; document.documentElement.dir = (lang === 'he') ? 'rtl' : 'ltr'; + +// ─── Task text limit ─────────────────────────────────────────────────────── +// The number server.js stores and mcp-task-manager.js advertises (task-text-limit.js). +// A maxlength attribute cuts a paste without a word, so a field that holds prose +// also carries a counter: it reaches MAX_TASK_TEXT exactly where the text was cut. +const MAX_TASK_TEXT = 20000; +function updTextCount(id, outId) { + const el = $i(id), out = $i(outId); + if (!el || !out) return; + const n = el.value.length; + out.textContent = n + ' / ' + MAX_TASK_TEXT; + out.classList.toggle('over', n >= MAX_TASK_TEXT); +} const TR = { uk: { 'nav.back': 'Назад', @@ -1646,7 +1660,8 @@
- + +
${(tk.description||'').length} / ${MAX_TASK_TEXT}
diff --git a/server.js b/server.js index 517aad3..6a4c457 100644 --- a/server.js +++ b/server.js @@ -79,6 +79,9 @@ const _ENV_AT_BOOT = { ...process.env }; } const multer = require('multer'); +// The one task-text limit, shared with the MCP schema and the two pages. A local +// require, so it sits below the .env loader like every other one (test/env-load-order). +const { MAX_TASK_TEXT } = require('./task-text-limit.js'); const cfgResolve = require('./config-resolve'); // Wave scheduling for multi-agent mode. Extracted so it can be tested without // spawning `claude` — see agent-dag.js and test/agent-dag.test.js. @@ -6122,7 +6125,7 @@ app.post('/api/internal/task-manager', express.json({ limit: '1mb' }), (req, res const depsJson = depends_on ? JSON.stringify(depends_on) : null; stmts.createTask.run( - id, String(title).substring(0, 200), String(description).substring(0, 2000), + id, String(title).substring(0, 200), String(description).substring(0, MAX_TASK_TEXT), '', // notes newStatus, // 'todo' → immediately eligible for processQueue; the rest are board rows 0, // sort_order @@ -6258,7 +6261,7 @@ app.post('/api/internal/task-manager', express.json({ limit: '1mb' }), (req, res stmts.createTask.run( taskId, String(td.title || `Step ${i + 1}`).substring(0, 200), - String(td.description || '').substring(0, 2000), + String(td.description || '').substring(0, MAX_TASK_TEXT), '', // notes 'todo', i * 1000, // sort_order @@ -7423,7 +7426,7 @@ app.post('/api/tasks', (req, res) => { let _wt = null; try { _wt = setupUnitWorktree('task', id, workdir); } catch (e) { if (e.code === 'GIT_UNAVAILABLE') return res.status(400).json({ error: e.message }); throw e; } - stmts.createTask.run(id, String(title).substring(0,200), String(description).substring(0,2000), String(notes||'').substring(0,2000), sqlVal(status), sqlVal(sort_order), sqlVal(session_id)||null, sqlVal(_wt ? _wt.workdir : workdir)||null, sqlVal(model), sqlVal(mode), sqlVal(agent_mode), sqlVal(max_turns), sqlVal(attachments)||null, sqlVal(depends_on)||null, sqlVal(chain_id)||null, sqlVal(source_session_id)||null, sqlVal(scheduled_at)||null, sqlVal(recurrence)||null, sqlVal(recurrence_end_at)||null, sqlVal(effort)||null, sqlVal(run_engine)||null, sqlVal(bot_id)||null); + stmts.createTask.run(id, String(title).substring(0,200), String(description).substring(0, MAX_TASK_TEXT), String(notes||'').substring(0, MAX_TASK_TEXT), sqlVal(status), sqlVal(sort_order), sqlVal(session_id)||null, sqlVal(_wt ? _wt.workdir : workdir)||null, sqlVal(model), sqlVal(mode), sqlVal(agent_mode), sqlVal(max_turns), sqlVal(attachments)||null, sqlVal(depends_on)||null, sqlVal(chain_id)||null, sqlVal(source_session_id)||null, sqlVal(scheduled_at)||null, sqlVal(recurrence)||null, sqlVal(recurrence_end_at)||null, sqlVal(effort)||null, sqlVal(run_engine)||null, sqlVal(bot_id)||null); if (_wt) stmts.setTaskGit.run(_wt.workdir, _wt.git_root, _wt.git_branch, id); const task = stmts.getTask.get(id); if (status === 'todo') setImmediate(processQueue); @@ -7459,8 +7462,8 @@ app.put('/api/tasks/:id', (req, res) => { } } stmts.updateTask.run( - String(title).substring(0,200), String(description).substring(0,2000), - String(notes||'').substring(0,2000), + String(title).substring(0,200), String(description).substring(0, MAX_TASK_TEXT), + String(notes||'').substring(0, MAX_TASK_TEXT), sqlVal(status), sqlVal(sort_order), sqlVal(session_id) || null, sqlVal(workdir) || null, sqlVal(model), sqlVal(mode), sqlVal(agent_mode), sqlVal(max_turns), sqlVal(attachments) || null, sqlVal(depends_on) || null, sqlVal(chain_id) || null, sqlVal(source_session_id) || null, @@ -7621,8 +7624,8 @@ app.post('/api/task-chains/:id/tasks', (req, res) => { const chainStatus = deriveChainStatus(req.params.id); const taskStatus = (chainStatus === 'in_progress' || chainStatus === 'todo') ? 'todo' : 'backlog'; const taskId = genId(); - stmts.createTask.run(taskId, String(title).substring(0, 200), String(description).substring(0, 2000), - String(notes || '').substring(0, 2000), taskStatus, sortOrder, + stmts.createTask.run(taskId, String(title).substring(0, 200), String(description).substring(0, MAX_TASK_TEXT), + String(notes || '').substring(0, MAX_TASK_TEXT), taskStatus, sortOrder, chain.session_id || null, chain.workdir || null, chain.model || 'sonnet', chain.mode || 'auto', chain.agent_mode || 'single', chain.max_turns || UNATTENDED_MAX_TURNS, null, dependsOn, req.params.id, chain.source_session_id || null, @@ -7792,7 +7795,7 @@ app.post('/api/tasks/dispatch', (req, res) => { stmts.createTask.run( taskId, (t.title || t.role || 'Subtask').substring(0, 200), - (t.description || t.task || '').substring(0, 2000), + (t.description || t.task || '').substring(0, MAX_TASK_TEXT), '', // notes 'todo', i, // sort_order preserves plan ordering @@ -13230,7 +13233,7 @@ wss.on('connection', (ws) => { stmts.createTask.run( taskId, (a.role || 'Subtask').substring(0, 200), - (a.task || '').substring(0, 2000), + (a.task || '').substring(0, MAX_TASK_TEXT), '', 'todo', i, chainSessionId, _wdchainWd, sqlVal(model) || 'sonnet', 'auto', 'single', UNATTENDED_MAX_TURNS, null, realDeps.length ? JSON.stringify(realDeps) : null, diff --git a/task-text-limit.js b/task-text-limit.js new file mode 100644 index 0000000..f60ba51 --- /dev/null +++ b/task-text-limit.js @@ -0,0 +1,17 @@ +'use strict'; +// One number for how much text a task may carry, shared by every place that writes +// or renders it: the task INSERT/UPDATE statements in server.js, the MCP tool schema +// in mcp-task-manager.js, and the description/notes fields in public/schedule.html and +// public/kanban.html. Each page keeps its own copy of the literal because the two +// pages are single static files with no build step; test/task-text-limit.test.js fails +// if any copy drifts from this one. +// +// 2000 was the old cap, and it is smaller than a useful prompt handed to Claude: a task +// that names the files, the expected behaviour and the acceptance checks runs past it, +// and both the server and the textarea cut the end off with no message. 20000 leaves +// room for a long prompt while keeping a single task row small enough to list with the +// board (express.json and the SQLite column are far above it, so neither is the binding +// limit). +const MAX_TASK_TEXT = 20000; + +module.exports = { MAX_TASK_TEXT }; diff --git a/test/kanban-group-button.test.js b/test/kanban-group-button.test.js index 038d6db..20146f1 100644 --- a/test/kanban-group-button.test.js +++ b/test/kanban-group-button.test.js @@ -42,10 +42,16 @@ check('every inline