diff --git a/public/index.html b/public/index.html
index b39e82c..ed5908c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2667,8 +2667,8 @@
.modal .modal-body textarea { min-height: 200px; }
#cfgModal .modal {
min-width: 0 !important;
- width: calc(100vw - 16px) !important;
- max-width: calc(100vw - 16px) !important;
+ width: 100% !important;
+ max-width: calc(100vw - 24px) !important;
resize: none !important;
}
.dir-modal, .ssh-host-modal, .cmd-modal {
diff --git a/public/kanban.html b/public/kanban.html
index 38bd881..58ac6a5 100644
--- a/public/kanban.html
+++ b/public/kanban.html
@@ -313,7 +313,7 @@
.ov { padding: 8px; }
.modal { max-width: calc(100vw - 16px) !important; max-height: 92vh; max-height: 92dvh; box-sizing: border-box !important; }
.grid2 { grid-template-columns: 1fr; }
- .card-actions { opacity: 1; flex-wrap: wrap; justify-content: flex-end; gap: 2px; }
+ .card-actions { display: flex !important; opacity: 1; flex-wrap: wrap; justify-content: flex-end; gap: 2px; }
/* Prevent iOS auto-zoom on focus (requires ≥ 16px) */
.inp, .sel, .proj-dd-search input { font-size: 16px; }
}
@@ -1213,20 +1213,19 @@
}
// Self-heal on tab focus, visibility change, and network reconnection (#107)
-document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') {
- refresh(true);
- startAutoRefresh();
- }
-});
-window.addEventListener('focus', () => {
- refresh(true);
- startAutoRefresh();
-});
-window.addEventListener('online', () => {
+let _lastKbResync = 0;
+function triggerKbResync() {
+ const now = Date.now();
+ if (now - _lastKbResync < 1500) return;
+ _lastKbResync = now;
refresh(true);
startAutoRefresh();
+}
+document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') triggerKbResync();
});
+window.addEventListener('focus', triggerKbResync);
+window.addEventListener('online', triggerKbResync);
// ─── Stats ─────────────────────────────────────────────────────────────────
function updateStats(){
@@ -2533,6 +2532,8 @@
const taskPrompt = (tk.title + (tk.description ? '\n\n' + tk.description : '')).trim();
const taskInput = $i('dlgTask');
if (taskInput) taskInput.value = taskPrompt;
+ const handoffRadio = document.querySelector('input[name="dlgMode"][value="handoff"]');
+ if (handoffRadio) handoffRadio.checked = true;
closeModal();
openOverlay('delegateOv', { initialFocus: '#dlgTask', onEscape: closeDelegateModal });
}
@@ -2551,7 +2552,7 @@
const effort = $i('dlgEffort')?.value || undefined;
if (!agentId) { toast(t('dlg.no_agents'), true); return; }
- if (!task) { toast(t('modal.name'), true); return; }
+ if (!task) { toast(t('dlg.task.ph'), true); return; }
try {
const r = await apiFetch('/api/delegate', {
@@ -2580,6 +2581,7 @@
}
}
+let _dlgDetailPollTimer = null;
async function openDelegationDetail(delegationId) {
const del = _activeDelegations.find(d => d.id === delegationId);
if (!del) return;
@@ -2595,13 +2597,21 @@
}
await updateDelegationDialogView(delegationId);
openOverlay('delegationDetailOv', { initialFocus: '#dlgMsgInp', onEscape: closeDelegationDetail });
+ clearInterval(_dlgDetailPollTimer);
+ _dlgDetailPollTimer = setInterval(() => {
+ if (!_activeDelegationDetailId) {
+ clearInterval(_dlgDetailPollTimer);
+ return;
+ }
+ updateDelegationDialogView(_activeDelegationDetailId);
+ }, 2500);
}
async function updateDelegationDialogView(delegationId) {
try {
const res = await apiFetch(`/api/delegate/${delegationId}/dialog`).then(r => r.json());
const box = $i('dlgDetailBox');
- if (box) {
+ if (box && res && res.dialog !== undefined) {
box.textContent = res.dialog || '...';
box.scrollTop = box.scrollHeight;
}
@@ -2609,6 +2619,8 @@
}
function closeDelegationDetail() {
+ clearInterval(_dlgDetailPollTimer);
+ _dlgDetailPollTimer = null;
closeOverlay('delegationDetailOv');
_activeDelegationDetailId = null;
}
@@ -2619,11 +2631,16 @@
const msg = inp?.value?.trim();
if (!msg) return;
try {
- await apiFetch(`/api/delegate/${_activeDelegationDetailId}/message`, {
+ const r = await apiFetch(`/api/delegate/${_activeDelegationDetailId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg }),
});
+ if (!r.ok) {
+ const err = await r.json().catch(() => ({}));
+ toast(err.error || t('toast.err'), true);
+ return;
+ }
if (inp) inp.value = '';
await updateDelegationDialogView(_activeDelegationDetailId);
} catch (e) {
@@ -2634,7 +2651,12 @@
async function stopActiveDelegation() {
if (!_activeDelegationDetailId) return;
try {
- await apiFetch(`/api/delegate/${_activeDelegationDetailId}`, { method: 'DELETE' });
+ const r = await apiFetch(`/api/delegate/${_activeDelegationDetailId}`, { method: 'DELETE' });
+ if (!r.ok) {
+ const err = await r.json().catch(() => ({}));
+ toast(err.error || t('toast.err'), true);
+ return;
+ }
toast(t('dlg.toast.stopped'));
closeDelegationDetail();
await refresh(true);
diff --git a/server.js b/server.js
index f03c197..10a89d4 100644
--- a/server.js
+++ b/server.js
@@ -1814,6 +1814,7 @@ const MAX_TASK_WORKERS = Math.max(1, parseInt(process.env.MAX_TASK_WORKERS || '5
const taskRunning = new Set(); // task IDs currently executing
const runningTaskAborts = new Map(); // taskId → AbortController
const stoppingTasks = new Set(); // task IDs being manually stopped (onDone must not overwrite status)
+const activeDelegations = new Map(); // delegationId -> { id, agentId, mode, workdir, delegationDir, startedAt, watcher, taskId }
// task IDs started via the independent-worker path. Not a cap counter — the cap is
// global (see processQueue) — this only tells /api/running-sessions whether a task row
// still has a worker behind it.
@@ -2893,6 +2894,17 @@ setInterval(() => {
continue; // worker is alive
}
}
+ // Check if task is actively delegated to an external agent
+ let isDelegated = false;
+ for (const d of activeDelegations.values()) {
+ if (d.taskId === task.id) {
+ isDelegated = true;
+ break;
+ }
+ }
+ if (isDelegated) {
+ continue; // actively being handled by external agent, do not evict
+ }
// Worker is dead — recover
log.warn(`[watchdog] task "${task.title}" (${task.id}) stuck in_progress with no live worker, recovering`);
if (task.worker_pid) killByPid(task.worker_pid);
@@ -10705,7 +10717,6 @@ app.post('/api/tunnel/stop', (_, res) => {
// CROSS-AGENT DELEGATION
// ============================================
-const activeDelegations = new Map(); // delegationId -> { id, agentId, mode, workdir, delegationDir, startedAt, watcher }
const CROSSWORK_DIR = '.crosswork';
function getDelegationDir(workdir, delegationId) {
@@ -11263,6 +11274,19 @@ app.post('/api/delegate', express.json(), (req, res) => {
let session = sessionId ? stmts.getSession.get(sessionId) : null;
const taskRow = taskId ? stmts.getTask.get(taskId) : null;
+ if (taskId && !taskRow) {
+ return res.status(404).json({ error: 'Task not found' });
+ }
+ if (taskRow && (taskRow.status === 'done' || taskRow.status === 'cancelled')) {
+ return res.status(409).json({ error: `Cannot delegate a task that is already ${taskRow.status}` });
+ }
+ if (taskId) {
+ for (const d of activeDelegations.values()) {
+ if (d.taskId === taskId) {
+ return res.status(409).json({ error: `Task is already being delegated to ${d.agentLabel || d.agentId}` });
+ }
+ }
+ }
if (!session && taskRow?.session_id) {
session = stmts.getSession.get(taskRow.session_id);
}
@@ -11429,6 +11453,14 @@ app.delete('/api/delegate/:id', (req, res) => {
if (delegation.watcher) { try { delegation.watcher.close(); } catch {} }
// Remove state file so it won't be restored on next restart
try { fs.unlinkSync(path.join(delegation.delegationDir, 'state.json')); } catch {}
+ if (delegation.taskId) {
+ try {
+ const task = stmts.getTask.get(delegation.taskId);
+ if (task && task.status === 'in_progress') {
+ db.prepare(`UPDATE tasks SET status='todo', updated_at=datetime('now') WHERE id=?`).run(delegation.taskId);
+ }
+ } catch {}
+ }
activeDelegations.delete(req.params.id);
log.info('Delegation stopped', { delegationId: req.params.id });
res.json({ ok: true });
diff --git a/test/kanban-delegation.test.js b/test/kanban-delegation.test.js
index 089d956..999330f 100644
--- a/test/kanban-delegation.test.js
+++ b/test/kanban-delegation.test.js
@@ -76,5 +76,32 @@ check('kanban.html defines delegation lifecycle functions', () => {
assert.ok(kbSrc.includes('async function sendDelegationMsg()'), 'missing sendDelegationMsg');
});
+check('server.js watchdog spares active delegated tasks from eviction', () => {
+ const wdBlock = srvSrc.slice(srvSrc.indexOf('// Watchdog: detect tasks stuck'));
+ assert.ok(wdBlock.includes('d.taskId === task.id'), 'watchdog does not check activeDelegations for taskId');
+ assert.ok(wdBlock.includes('if (isDelegated)'), 'watchdog does not skip recovering delegated tasks');
+});
+
+check('server.js /api/delegate validates task status and blocks double delegation', () => {
+ assert.ok(srvSrc.includes("taskRow.status === 'done' || taskRow.status === 'cancelled'"), 'missing done/cancelled task check');
+ assert.ok(srvSrc.includes('Task is already being delegated to'), 'missing already-delegated task check');
+});
+
+check('server.js DELETE /api/delegate/:id reverts in_progress task to todo', () => {
+ const delBlock = srvSrc.slice(srvSrc.indexOf("app.delete('/api/delegate/:id'"));
+ assert.ok(delBlock.includes("status='todo'"), 'DELETE /api/delegate does not revert task status');
+});
+
+check('kanban.html delegation dialog viewer polls for updates and verifies response ok', () => {
+ assert.ok(kbSrc.includes('_dlgDetailPollTimer = setInterval'), 'missing interval polling in openDelegationDetail');
+ assert.ok(kbSrc.includes('clearInterval(_dlgDetailPollTimer)'), 'missing clearInterval in closeDelegationDetail');
+ assert.ok(kbSrc.includes('if (!r.ok)'), 'missing r.ok check in delegation actions');
+});
+
+check('kanban.html debounces self-heal reconnect events', () => {
+ assert.ok(kbSrc.includes('function triggerKbResync()'), 'missing triggerKbResync function');
+ assert.ok(kbSrc.includes('now - _lastKbResync < 1500'), 'missing debounce timing check');
+});
+
if (failed) { console.log(`\n${failed} test(s) failed`); process.exit(1); }
console.log('\nAll kanban-delegation tests passed');
diff --git a/test/responsive-ui.test.js b/test/responsive-ui.test.js
index 18fcbf2..7dd5d03 100644
--- a/test/responsive-ui.test.js
+++ b/test/responsive-ui.test.js
@@ -47,5 +47,10 @@ check('project dropdown buttons set title attribute for full name tooltips', ()
assert.ok(/ddName\.title\s*=\s*fullName/.test(kbSrc), 'kanban.html should set title on project name');
});
+check('kanban.html mobile media query forces card-actions display on touch screens', () => {
+ const media800 = kbSrc.slice(kbSrc.indexOf('@media (max-width: 800px)'));
+ assert.ok(/\.card-actions\s*\{[^}]*display:\s*flex\s*!important/.test(media800), 'missing display: flex !important for card-actions in mobile query');
+});
+
if (failed) { console.log(`\n${failed} test(s) failed`); process.exit(1); }
console.log('\nAll responsive-ui tests passed');