claude-code-kanban 4.19.0 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,15 @@ npx claude-code-kanban --open
30
30
 
31
31
  ### 3. Use Claude Code as usual
32
32
 
33
- Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE. It never directs Claude's work.
33
+ Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE. Moving a card is the one thing that flows the other way: the board notifies the owning session with the card subject and description, so the agent can act on it.
34
+
35
+ > **Empty board?** Claude Code ships the task tools off by default on some models — currently Opus 5, Fable 5 — so nothing writes task files and the board stays empty. Turn them on in `~/.claude/settings.json`:
36
+ >
37
+ > ```json
38
+ > { "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "true" } }
39
+ > ```
40
+ >
41
+ > Then restart Claude Code. You can also add a task by hand from the board's Pending column.
34
42
 
35
43
  ## Features
36
44
 
@@ -0,0 +1,99 @@
1
+ // Session event doorbell: tells a live session that the board moved one of its tasks.
2
+ // Its own module so the behaviour is unit-testable without booting the server -- the
3
+ // bucket-map invariants here are the difference between a bounded queue and a map that
4
+ // grows one permanent entry per session id ever named in a request path.
5
+
6
+ // Tells a live session that the board moved one of its tasks. The postman monitor
7
+ // (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
8
+ // each line, which Claude Code delivers into that session as a task notification.
9
+ //
10
+ // Deliberately in-memory and lossy. The task file is the durable command -- a dropped
11
+ // event only means the agent notices on its next turn instead of immediately -- so a
12
+ // disk queue would buy nothing. Reading consumes, so a restarted postman never replays
13
+ // a backlog and acts on the same move twice.
14
+ //
15
+ // A bucket exists only while it holds something: an undelivered line or a waiting
16
+ // poller. Without that, the map would grow one permanent entry per session id ever
17
+ // asked for -- and the id comes straight off the request path.
18
+ const sessionEventBuckets = new Map();
19
+
20
+ // The line reaches the model verbatim at hook trust level. The task id is caller-supplied
21
+ // and the subject and description are board-authored, so the length cap and the
22
+ // control-character scrub are what hold the one-line-per-event contract: a newline inside
23
+ // a description becomes a space rather than a second forged event.
24
+ //
25
+ // One cap for the whole line rather than one per field, because the description comes
26
+ // last: truncation eats its tail first and leaves the machine-readable head intact.
27
+ function sanitizeEventLine(line) {
28
+ return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
29
+ }
30
+
31
+ // Everything after `description=` is the description verbatim to end of line, so no amount
32
+ // of board text can pose as a further field. That leaves the subject as the only value
33
+ // that needs delimiting.
34
+ function formatTaskMoved(taskId, prevStatus, task) {
35
+ const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
36
+ const head = `cck:1 task.moved ${taskId} ${prevStatus || 'none'}>${task.status} subject="${subject}"`;
37
+ return task.description ? `${head} description=${task.description}` : head;
38
+ }
39
+
40
+ function enqueueSessionEvent(sessionId, line) {
41
+ const text = sanitizeEventLine(line);
42
+ if (!sessionId || !text) return;
43
+ let bucket = sessionEventBuckets.get(sessionId);
44
+ if (!bucket) {
45
+ bucket = { queue: [], waiters: new Set() };
46
+ sessionEventBuckets.set(sessionId, bucket);
47
+ }
48
+ bucket.queue.push(text);
49
+ // A session with no postman attached must not grow without bound.
50
+ if (bucket.queue.length > 50) bucket.queue.splice(0, bucket.queue.length - 50);
51
+ for (const wake of [...bucket.waiters]) wake();
52
+ }
53
+
54
+ // Long-poll drained by the postman monitor. Routing stays in server.js; this is the handler.
55
+ function handleSessionEvents(req, res) {
56
+ const { sessionId } = req.params;
57
+ const bucket = sessionEventBuckets.get(sessionId);
58
+ const wait = Math.min(Math.max(Number(req.query.wait) || 0, 0), 120);
59
+
60
+ // A postman is armed by a skill invocation, so it can attach long after the board moved
61
+ // something. Those lines are read as instructions, and an hours-old instruction is worse
62
+ // than no instruction, so the grant starts the session's history rather than inheriting
63
+ // it: `first=1` drops the whole backlog. drain() (not a bare truncate) so an emptied
64
+ // bucket is still evicted from the map.
65
+ if (bucket && req.query.first === '1') drain(sessionId, bucket);
66
+
67
+ if (bucket && bucket.queue.length) return res.json({ events: drain(sessionId, bucket) });
68
+ if (!wait) return res.json({ events: [] });
69
+
70
+ const pending = bucket || { queue: [], waiters: new Set() };
71
+ sessionEventBuckets.set(sessionId, pending);
72
+
73
+ const send = () => {
74
+ // Set.delete is the whole idempotency story: whichever of enqueue, timeout, or
75
+ // client disconnect gets here first is the one that answers.
76
+ if (!pending.waiters.delete(send)) return;
77
+ clearTimeout(timer);
78
+ req.removeListener('close', send);
79
+ const events = drain(sessionId, pending);
80
+ if (!res.writableEnded) res.json({ events });
81
+ };
82
+ const timer = setTimeout(send, wait * 1000);
83
+ pending.waiters.add(send);
84
+ req.on('close', send);
85
+ }
86
+
87
+ function drain(sessionId, bucket) {
88
+ const events = bucket.queue.splice(0);
89
+ if (!bucket.queue.length && !bucket.waiters.size) sessionEventBuckets.delete(sessionId);
90
+ return events;
91
+ }
92
+
93
+ module.exports = {
94
+ sessionEventBuckets,
95
+ sanitizeEventLine,
96
+ formatTaskMoved,
97
+ enqueueSessionEvent,
98
+ handleSessionEvents,
99
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.19.0",
3
+ "version": "4.20.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.3.3",
4
- "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, and a skill to drive the board from a session"
3
+ "version": "2.7.1",
4
+ "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
5
+ "experimental": {
6
+ "monitors": "./monitors.json"
7
+ }
5
8
  }
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "name": "kanban-doorbell",
4
+ "description": "Notifies this session when its tasks are moved on the kanban board.",
5
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\"",
6
+ "when": "on-skill-invoke:claude-code-kanban:kanban-follow"
7
+ }
8
+ ]
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // Kanban -> session doorbell.
3
+ //
4
+ // Claude Code delivers every line a monitor prints to the owning session as a task
5
+ // notification, so this process is the only way board activity can reach a session that
6
+ // is sitting idle. It long-polls the kanban server for events addressed to this session
7
+ // and prints them one per line.
8
+ //
9
+ // Pairing is free: CLAUDE_CODE_SESSION_ID is inherited from the session that spawned us,
10
+ // so the id we poll with is the same id the hooks report. No cwd or pid guessing.
11
+ //
12
+ // The lines we print carry board text (the card subject and description), which the
13
+ // session is told to read as the user's own brief. That is only safe because we are armed
14
+ // by an explicit `kanban-follow` invocation: the user asked to follow the board before anything the
15
+ // board says can reach the model.
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ const SESSION_ID = process.env.CLAUDE_CODE_SESSION_ID;
22
+ const SERVER_INFO = path.join(os.homedir(), '.claude', '.cck', 'server.json');
23
+ // The server caps its own wait at 120s. Sitting at the ceiling halves every recurring
24
+ // cost -- handshake, route walk, timer, empty response -- and costs no event latency,
25
+ // because an enqueue wakes the poll immediately.
26
+ const WAIT_SEC = 120;
27
+ const RETRY_MS = 15000;
28
+
29
+ if (!SESSION_ID) process.exit(0);
30
+
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+
33
+ // Re-read every cycle rather than caching: it is how we follow the board across a
34
+ // restart onto a different port. A file left behind by a crashed server names a port
35
+ // something else may now hold, so trust it only while its pid is alive.
36
+ function serverUrl() {
37
+ const { port, pid } = JSON.parse(fs.readFileSync(SERVER_INFO, 'utf8'));
38
+ if (pid) process.kill(pid, 0);
39
+ return `http://127.0.0.1:${port}`;
40
+ }
41
+
42
+ // Once per process, not once per poll: the grant means "follow the board from here on", so
43
+ // the first attach throws away whatever queued up before it. A later reconnect must not
44
+ // discard again -- by then the queue holds events the user is owed.
45
+ let firstAttach = true;
46
+
47
+ async function poll(base) {
48
+ const first = firstAttach ? '&first=1' : '';
49
+ const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}`;
50
+ const res = await fetch(url, { signal: AbortSignal.timeout((WAIT_SEC + 15) * 1000) });
51
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
52
+ firstAttach = false;
53
+ const { events } = await res.json();
54
+ return Array.isArray(events) ? events : [];
55
+ }
56
+
57
+ (async () => {
58
+ for (;;) {
59
+ try {
60
+ for (const line of await poll(serverUrl())) console.log(line);
61
+ } catch (_) {
62
+ // No board yet, or it went away. It may come back later in the session, so keep
63
+ // waiting quietly -- a missing server is the normal case, not an error.
64
+ await sleep(RETRY_MS);
65
+ }
66
+ }
67
+ })();
@@ -1,60 +1,53 @@
1
1
  ---
2
2
  name: kanban
3
- description: Drive the claude-code-kanban dashboard from this session — focus the current session in the browser, pin/unpin it in the sidebar, preview a markdown or HTML file, link a document to the session, or inspect session stats and messages. Use when the user mentions kanban or cck.
3
+ description: Drive the kanban board — open, pin, preview, link, inspect.
4
4
  argument-hint: '[open|pin|unpin|preview|link] [target]'
5
+ disable-model-invocation: true
5
6
  ---
6
7
 
7
8
  # Kanban Skill
8
9
 
9
- The current Claude session id is `${CLAUDE_SESSION_ID}` (substituted when this skill loads), so the user never needs to look it up.
10
+ This session id is `${CLAUDE_SESSION_ID}`, substituted when the skill loads.
10
11
 
11
- When the user passes arguments, map them to the matching command below (`open` → `session open`, `pin`/`unpin`/`pins` → `session pin`/`--unpin`/`session pins`, `preview` → `preview-doc`, `link` → `link-doc`, `list`/`view`/`peek` → the read-only verbs); with no arguments, open the current session.
12
+ An argument names the section below that handles it; with no argument, open the current session. Prefer the bare `claude-code-kanban` binary, falling back to `npx claude-code-kanban` when it is off PATH or the user asks for npx.
12
13
 
13
- Prefer the bare `claude-code-kanban` binary; fall back to `npx claude-code-kanban` when it is not on PATH, or when the user asks for npx explicitly.
14
+ To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:kanban-follow`.
14
15
 
15
- ## Open the current session in kanban
16
+ ## `open` — the current session
16
17
 
17
- Primary use case. Pins the active session in the sidebar and switches to the Active tab.
18
+ Pins the session and switches the board to the Active tab.
18
19
 
19
20
  ```bash
20
21
  claude-code-kanban session open ${CLAUDE_SESSION_ID}
21
22
  ```
22
23
 
23
- ## Pin the current session
24
-
25
- Pins the session so it stays visible regardless of filters. Three states: `pinned` (default), `sticky` (always at the top), or cleared with `--unpin`.
24
+ ## `pin` — keep the session visible
26
25
 
27
26
  ```bash
28
27
  claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
29
- claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
28
+ claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # always at the top
30
29
  claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
30
+ claude-code-kanban session pins # list pinned; --sticky narrows
31
31
  ```
32
32
 
33
- ## List pinned sessions
34
-
35
- ```bash
36
- claude-code-kanban session pins # all pinned/sticky
37
- claude-code-kanban session pins --sticky # sticky only
38
- ```
39
-
40
- ## Preview a file in kanban
33
+ ## `preview` — open a file in the modal
41
34
 
42
- Opens a markdown or standalone HTML file in the preview modal (HTML renders live in a sandboxed iframe, so sibling assets like `./style.css` do not load). Relative paths are fine — the server resolves to absolute.
35
+ Markdown or standalone HTML. HTML renders in a sandboxed iframe, so sibling assets like `./style.css` do not load. Relative paths are fine — the server resolves them.
43
36
 
44
37
  ```bash
45
- claude-code-kanban preview-doc <path-to-file.md|.html> --session ${CLAUDE_SESSION_ID}
38
+ claude-code-kanban preview-doc <file.md|.html> --session ${CLAUDE_SESSION_ID}
46
39
  ```
47
40
 
48
- ## Link a document to the session (no modal)
41
+ ## `link` — attach a doc without the modal
49
42
 
50
- Same idea as `preview-doc`, but it only attaches the file to the session's linked docs in the sidebar — nothing pops up, so it is the safe choice while the user is working. Any extension is linkable.
43
+ Adds the file to the session's linked docs in the sidebar. Any extension, and nothing pops up, so it is the safe choice while the user is working.
51
44
 
52
45
  ```bash
53
- claude-code-kanban link-doc <path-to-file> --session ${CLAUDE_SESSION_ID} # link
54
- claude-code-kanban link-doc <path-to-file> --session ${CLAUDE_SESSION_ID} --unlink # remove
46
+ claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} # link
47
+ claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} --unlink # remove
55
48
  ```
56
49
 
57
- ## Inspect sessions (read-only)
50
+ ## `list` / `view` / `peek` — read-only
58
51
 
59
52
  ```bash
60
53
  claude-code-kanban session list --active # recent active sessions
@@ -64,12 +57,10 @@ claude-code-kanban session view ${CLAUDE_SESSION_ID} # full stats
64
57
  claude-code-kanban session peek ${CLAUDE_SESSION_ID} --limit 20 # last 20 messages (server caps at 50)
65
58
  ```
66
59
 
67
- `session list` shows 10 rows by default and always includes pinned sessions, sticky first — `--no-pins` disables both.
68
-
69
- Add `--json` to any list-style verb for machine-readable output.
60
+ `session list` shows 10 rows and always includes pinned sessions, sticky first (`--no-pins` disables both). `--json` works on any list-style verb.
70
61
 
71
62
  ## Troubleshooting
72
63
 
73
- `claude-code-kanban help <command>` prints the authoritative flags for any command — read it instead of guessing.
64
+ `claude-code-kanban help <command>` prints the authoritative flags — read it instead of guessing.
74
65
 
75
66
  - **"Cannot reach cck server…"** → the error names the port it tried. Ask the user to start the server with `claude-code-kanban`. If they run it elsewhere, set `PORT=<n>` when invoking the CLI.
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: kanban-follow
3
+ description: Let kanban card moves drive this session.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Follow the kanban board
8
+
9
+ The user typing this skill is the whole grant: it armed the doorbell — a monitor watching the board for moves of *this session's* tasks, live for the rest of the session. Confirm in one line and run no command. Moves made before it attached are discarded, so every line you get is current.
10
+
11
+ ```
12
+ cck:1 task.moved <taskId> <from>><to> subject="<subject>" description=<description>
13
+ ```
14
+
15
+ `description=` is omitted when the card has none. Everything after it is the description verbatim to end of line, truncated if long — read the task with `TaskGet` for the full text or for `activeForm` and `blockedBy`. Only moves are announced.
16
+
17
+ **A move is an instruction, not an FYI** — the user dragging a card is the user telling you something, and the subject and description are their brief for the work:
18
+
19
+ | Transition | What the user means |
20
+ |---|---|
21
+ | `pending>in_progress` / `todo>in_progress` | Start this task now. |
22
+ | `in_progress>pending` / `in_progress>todo` | Stop working on it and park it. |
23
+ | `*>completed` | The user considers it done — do not keep working on it. |
24
+ | `*>cancelled` | Abandon it. Undo nothing unless asked. |
25
+
26
+ Act on the newest line per task; a card dragged twice means only its final position. When a move contradicts your current work, the board wins.
27
+
28
+ Delivery is best-effort and the task file is the authority, so a missed line only delays you to your next turn.
29
+
30
+ ## Finish the task on the board
31
+
32
+ A move hands you work; the task file is where you hand it back. When the work a `>in_progress` move asked for is done, set that task to `completed` with `TaskUpdate` before you reply — the card moves itself, and the board is where the user is watching. A card left in In Progress reads as work still running.
33
+
34
+ Your own `TaskUpdate` writes the task file rather than going through the board, so it rings no doorbell: there is no echo to guard against.
35
+
36
+ ## Troubleshooting
37
+
38
+ - **No lines arrive** → the doorbell arms once per session, on the namespaced dispatch: `/claude-code-kanban:kanban-follow`. A session that never got it never hears the board.
39
+ - **"Cannot reach cck server…"** → the error names the port it tried. Ask the user to start the server with `claude-code-kanban`.
package/public/app.js CHANGED
@@ -1425,6 +1425,7 @@ function toggleAgentPin(agentId) {
1425
1425
  }
1426
1426
  savePins(currentSessionId, currentPins);
1427
1427
  renderMessages(currentMessages);
1428
+ renderSessions();
1428
1429
  renderAgentFooter();
1429
1430
  }
1430
1431
 
@@ -1476,6 +1477,7 @@ function togglePin(msgIndex) {
1476
1477
  }
1477
1478
  savePins(currentSessionId, currentPins);
1478
1479
  renderMessages(currentMessages);
1480
+ renderSessions();
1479
1481
  updateMsgDetailPinState();
1480
1482
  }
1481
1483
 
@@ -1485,6 +1487,7 @@ function unpinById(pinIdx) {
1485
1487
  currentPins.splice(pinIdx, 1);
1486
1488
  savePins(currentSessionId, currentPins);
1487
1489
  renderMessages(currentMessages);
1490
+ renderSessions();
1488
1491
  if (wasAgent) renderAgentFooter();
1489
1492
  updateMsgDetailPinState();
1490
1493
  }
@@ -1900,8 +1903,22 @@ function _setModalWidth(modal, slot, on, maxWidth, width) {
1900
1903
  }
1901
1904
  }
1902
1905
 
1906
+ function _modalEl(modalId) {
1907
+ return document.querySelector(`#${modalId} .modal`);
1908
+ }
1909
+
1910
+ // The opt-in marker for fullscreen and drag-resize alike: the presence of a
1911
+ // `<modalId>-fullscreen-btn` button in the markup.
1912
+ function _forEachFullscreenModal(cb) {
1913
+ for (const btn of document.querySelectorAll('[id$="-fullscreen-btn"]')) {
1914
+ const modalId = btn.id.replace(/-fullscreen-btn$/, '');
1915
+ const modal = _modalEl(modalId);
1916
+ if (modal) cb(modalId, modal);
1917
+ }
1918
+ }
1919
+
1903
1920
  function _applyModalFullscreen(modalId, on) {
1904
- const modal = document.querySelector(`#${modalId} .modal`);
1921
+ const modal = _modalEl(modalId);
1905
1922
  modal.classList.toggle('fullscreen', on);
1906
1923
  _setModalWidth(modal, 'Fs', on, '', '');
1907
1924
  updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, on);
@@ -1909,16 +1926,15 @@ function _applyModalFullscreen(modalId, on) {
1909
1926
 
1910
1927
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1911
1928
  function toggleModalFullscreen(modalId) {
1912
- const on = !document.querySelector(`#${modalId} .modal`).classList.contains('fullscreen');
1929
+ const on = !_modalEl(modalId).classList.contains('fullscreen');
1913
1930
  _applyModalFullscreen(modalId, on);
1914
1931
  localStorage.setItem(`modal-fullscreen-${modalId}`, String(on));
1915
1932
  }
1916
1933
 
1917
1934
  function loadModalFullscreen() {
1918
- for (const btn of document.querySelectorAll('[id$="-fullscreen-btn"]')) {
1919
- const modalId = btn.id.replace(/-fullscreen-btn$/, '');
1935
+ _forEachFullscreenModal((modalId) => {
1920
1936
  if (localStorage.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
1921
- }
1937
+ });
1922
1938
  }
1923
1939
 
1924
1940
  // Hides the overlay only — the fullscreen state stays on the dialog, it is a
@@ -1937,6 +1953,72 @@ function updateFullscreenBtnIcon(btnId, isFullscreen) {
1937
1953
  : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
1938
1954
  }
1939
1955
 
1956
+ // Free-form modal resize: drag the bottom-right grip, double-click it to reset.
1957
+ // The size lives in CSS custom properties + `.user-sized` (see style.css) rather
1958
+ // than inline width/height, so it never collides with the inline-style stash
1959
+ // that fullscreen and "Show more" do via _setModalWidth. The viewport cap lives
1960
+ // only in the CSS min(); JS clamps just the minimums.
1961
+ const MODAL_MIN_W = 360;
1962
+ const MODAL_MIN_H = 240;
1963
+
1964
+ function _setModalUserSize(modal, w, h) {
1965
+ modal.style.setProperty('--user-modal-w', w);
1966
+ modal.style.setProperty('--user-modal-h', h);
1967
+ }
1968
+
1969
+ function initModalResize() {
1970
+ _forEachFullscreenModal((modalId, modal) => {
1971
+ const wKey = `modal-width-${modalId}`;
1972
+ const hKey = `modal-height-${modalId}`;
1973
+ const savedW = localStorage.getItem(wKey);
1974
+ const savedH = localStorage.getItem(hKey);
1975
+ if (savedW && savedH) {
1976
+ _setModalUserSize(modal, savedW, savedH);
1977
+ modal.classList.add('user-sized');
1978
+ }
1979
+
1980
+ const handle = document.createElement('div');
1981
+ handle.className = 'modal-resize-handle';
1982
+ handle.title = 'Drag to resize · double-click to reset';
1983
+ modal.appendChild(handle);
1984
+
1985
+ let startW, startH, w, h;
1986
+ _initDragResize(handle, {
1987
+ onStart() {
1988
+ startW = modal.offsetWidth;
1989
+ startH = modal.offsetHeight;
1990
+ // autoSizeModal / "Show more" may have widened the dialog inline
1991
+ // before the first drag; inline width beats the .user-sized CSS.
1992
+ modal.style.width = '';
1993
+ modal.style.maxWidth = '';
1994
+ // Vars and class land together, so `.user-sized` never reads an
1995
+ // undefined var.
1996
+ _setModalUserSize(modal, `${startW}px`, `${startH}px`);
1997
+ modal.classList.add('user-sized');
1998
+ },
1999
+ // The overlay centers the modal, so both edges move — double the delta
2000
+ // to keep the grip under the cursor.
2001
+ onMove(dx, dy) {
2002
+ w = Math.max(MODAL_MIN_W, startW + dx * 2);
2003
+ h = Math.max(MODAL_MIN_H, startH + dy * 2);
2004
+ _setModalUserSize(modal, `${w}px`, `${h}px`);
2005
+ },
2006
+ onEnd() {
2007
+ if (w && h) {
2008
+ localStorage.setItem(wKey, `${w}px`);
2009
+ localStorage.setItem(hKey, `${h}px`);
2010
+ }
2011
+ },
2012
+ });
2013
+
2014
+ handle.addEventListener('dblclick', () => {
2015
+ modal.classList.remove('user-sized');
2016
+ localStorage.removeItem(wKey);
2017
+ localStorage.removeItem(hKey);
2018
+ });
2019
+ });
2020
+ }
2021
+
1940
2022
  const MODAL_ZOOM_KEY = 'modal-zoom';
1941
2023
  const MODAL_ZOOM_MIN = 0.7;
1942
2024
  const MODAL_ZOOM_MAX = 2.0;
@@ -2325,7 +2407,8 @@ function _applyExpandToggle(btn, fullEl) {
2325
2407
  const panel = btn.closest('.message-panel');
2326
2408
  if (panel) panel.classList.toggle('msg-expanded-wide', expand);
2327
2409
  const modal = btn.closest('.modal');
2328
- if (modal) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
2410
+ // A user-dragged size is an explicit choice — don't widen over it.
2411
+ if (modal && !modal.classList.contains('user-sized')) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
2329
2412
  }
2330
2413
  function _toggleExpand(btn) {
2331
2414
  const f = document.getElementById(btn.dataset.expandId);
@@ -2343,7 +2426,7 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
2343
2426
  }
2344
2427
 
2345
2428
  function autoSizeModal(modal, body) {
2346
- if (modal.classList.contains('fullscreen')) return;
2429
+ if (modal.classList.contains('fullscreen') || modal.classList.contains('user-sized')) return;
2347
2430
  modal.style.maxWidth = '';
2348
2431
  modal.classList.remove('has-mermaid');
2349
2432
  const hasMermaid = body.querySelector('pre.mermaid') !== null;
@@ -3299,11 +3382,19 @@ function renderKanban() {
3299
3382
  completedCount.textContent = completed.length;
3300
3383
 
3301
3384
  const emptyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>`;
3385
+ const plusIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg>`;
3302
3386
 
3303
- pendingTasks.innerHTML =
3304
- pending.length > 0
3305
- ? pending.map(renderTaskCard).join('')
3306
- : `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`;
3387
+ // Adding is a live text input inside the column, so a background refresh would blow it
3388
+ // away mid-typing -- leave the column alone until the input is gone.
3389
+ if (!addingTask) {
3390
+ const addTile = canAddTask()
3391
+ ? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
3392
+ : '';
3393
+ pendingTasks.innerHTML =
3394
+ pending.length > 0
3395
+ ? pending.map(renderTaskCard).join('') + addTile
3396
+ : addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`;
3397
+ }
3307
3398
 
3308
3399
  inProgressTasks.innerHTML =
3309
3400
  inProgress.length > 0
@@ -3334,6 +3425,78 @@ function renderKanban() {
3334
3425
 
3335
3426
  //#endregion
3336
3427
 
3428
+ //#region ADD_TASK
3429
+ let addingTask = false;
3430
+
3431
+ // A task the user types is theirs to place, and the only session it can belong to is the
3432
+ // one on screen -- the project and all-sessions views span many task dirs, so there is no
3433
+ // single target to write into.
3434
+ function canAddTask() {
3435
+ return viewMode === 'session' && !!currentSessionId;
3436
+ }
3437
+
3438
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
3439
+ function startAddTask(tile) {
3440
+ if (addingTask) return;
3441
+ addingTask = true;
3442
+
3443
+ const input = document.createElement('input');
3444
+ input.type = 'text';
3445
+ input.className = 'form-input column-add-input';
3446
+ input.placeholder = 'Task subject, Enter to add';
3447
+ tile.replaceWith(input);
3448
+ input.focus();
3449
+
3450
+ const reset = () => {
3451
+ addingTask = false;
3452
+ renderKanban();
3453
+ };
3454
+
3455
+ const save = async () => {
3456
+ // Enter and blur both submit, and Enter's own save disables the input -- which blurs
3457
+ // it. Dropping both handlers first is what keeps that from posting the subject twice.
3458
+ input.onkeydown = null;
3459
+ input.onblur = null;
3460
+
3461
+ const subject = input.value.trim();
3462
+ if (!subject) return reset();
3463
+ input.disabled = true;
3464
+ const sessionId = currentSessionId;
3465
+ try {
3466
+ const res = await fetch(`/api/tasks/${sessionId}`, {
3467
+ method: 'POST',
3468
+ headers: { 'Content-Type': 'application/json' },
3469
+ body: JSON.stringify({ subject }),
3470
+ });
3471
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3472
+ // The response carries the finished task, and the watcher will resend it within the
3473
+ // SSE debounce anyway -- so show it now rather than paying a session refetch for it.
3474
+ const { task } = await res.json();
3475
+ currentTasks.push({ ...task, sessionId });
3476
+ addingTask = false;
3477
+ renderKanban();
3478
+ } catch (error) {
3479
+ console.error('Failed to create task:', error);
3480
+ showToast('Failed to create task', 'error');
3481
+ reset();
3482
+ }
3483
+ };
3484
+
3485
+ input.onkeydown = (e) => {
3486
+ e.stopPropagation();
3487
+ if (e.key === 'Enter') {
3488
+ e.preventDefault();
3489
+ save();
3490
+ } else if (e.key === 'Escape') {
3491
+ e.preventDefault();
3492
+ input.onblur = null;
3493
+ reset();
3494
+ }
3495
+ };
3496
+ input.onblur = () => save();
3497
+ }
3498
+ //#endregion
3499
+
3337
3500
  //#region DRAG_DROP
3338
3501
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
3339
3502
  function onCardDragStart(e) {
@@ -6313,68 +6476,79 @@ function loadSidebarState() {
6313
6476
  }
6314
6477
  }
6315
6478
 
6316
- function initSidebarResize() {
6317
- const sidebar = document.querySelector('.sidebar');
6318
- const handle = document.getElementById('sidebar-resize');
6319
- let startX, startWidth;
6479
+ // Shared drag-session lifecycle for every resize grip (sidebar, panels, modal):
6480
+ // the `.dragging` class, text-selection suppression, and document-level
6481
+ // listener add/remove live here once. Callers keep only their geometry.
6482
+ // onStart may return false to veto the drag.
6483
+ function _initDragResize(handle, { onStart, onMove, onEnd }) {
6484
+ let startX, startY;
6320
6485
 
6321
6486
  handle.addEventListener('mousedown', (e) => {
6322
- if (sidebar.classList.contains('collapsed')) return;
6487
+ if (onStart && onStart() === false) return;
6323
6488
  startX = e.clientX;
6324
- startWidth = sidebar.offsetWidth;
6325
- sidebar.classList.add('resizing');
6489
+ startY = e.clientY;
6326
6490
  handle.classList.add('dragging');
6327
6491
  document.body.style.userSelect = 'none';
6328
- document.addEventListener('mousemove', onMove);
6329
- document.addEventListener('mouseup', onUp);
6492
+ document.addEventListener('mousemove', move);
6493
+ document.addEventListener('mouseup', up);
6330
6494
  e.preventDefault();
6331
6495
  });
6332
6496
 
6333
- function onMove(e) {
6334
- const w = Math.min(600, Math.max(200, startWidth + e.clientX - startX));
6335
- sidebar.style.setProperty('--sidebar-width', `${w}px`);
6336
- sidebar.style.width = `${w}px`;
6497
+ function move(e) {
6498
+ onMove(e.clientX - startX, e.clientY - startY);
6337
6499
  }
6338
6500
 
6339
- function onUp() {
6340
- sidebar.classList.remove('resizing');
6501
+ function up() {
6341
6502
  handle.classList.remove('dragging');
6342
6503
  document.body.style.userSelect = '';
6343
- document.removeEventListener('mousemove', onMove);
6344
- document.removeEventListener('mouseup', onUp);
6345
- localStorage.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
6504
+ document.removeEventListener('mousemove', move);
6505
+ document.removeEventListener('mouseup', up);
6506
+ onEnd();
6346
6507
  }
6347
6508
  }
6348
6509
 
6510
+ function initSidebarResize() {
6511
+ const sidebar = document.querySelector('.sidebar');
6512
+ const handle = document.getElementById('sidebar-resize');
6513
+ let startWidth;
6514
+
6515
+ _initDragResize(handle, {
6516
+ onStart() {
6517
+ if (sidebar.classList.contains('collapsed')) return false;
6518
+ startWidth = sidebar.offsetWidth;
6519
+ sidebar.classList.add('resizing');
6520
+ },
6521
+ onMove(dx) {
6522
+ const w = Math.min(600, Math.max(200, startWidth + dx));
6523
+ sidebar.style.setProperty('--sidebar-width', `${w}px`);
6524
+ sidebar.style.width = `${w}px`;
6525
+ },
6526
+ onEnd() {
6527
+ sidebar.classList.remove('resizing');
6528
+ localStorage.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
6529
+ },
6530
+ });
6531
+ }
6532
+
6349
6533
  function initPanelResize(panelId, handleId, cssVar, storageKey) {
6350
6534
  const panel = document.getElementById(panelId);
6351
6535
  const handle = document.getElementById(handleId);
6352
- let startX, startWidth;
6536
+ let startWidth;
6353
6537
 
6354
- handle.addEventListener('mousedown', (e) => {
6355
- startX = e.clientX;
6356
- startWidth = panel.offsetWidth;
6357
- panel.classList.add('resizing');
6358
- handle.classList.add('dragging');
6359
- document.body.style.userSelect = 'none';
6360
- document.addEventListener('mousemove', onMove);
6361
- document.addEventListener('mouseup', onUp);
6362
- e.preventDefault();
6538
+ _initDragResize(handle, {
6539
+ onStart() {
6540
+ startWidth = panel.offsetWidth;
6541
+ panel.classList.add('resizing');
6542
+ },
6543
+ onMove(dx) {
6544
+ const w = Math.max(200, startWidth - dx);
6545
+ panel.style.setProperty(cssVar, `${w}px`);
6546
+ },
6547
+ onEnd() {
6548
+ panel.classList.remove('resizing');
6549
+ localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
6550
+ },
6363
6551
  });
6364
-
6365
- function onMove(e) {
6366
- const w = Math.max(200, startWidth - (e.clientX - startX));
6367
- panel.style.setProperty(cssVar, `${w}px`);
6368
- }
6369
-
6370
- function onUp() {
6371
- panel.classList.remove('resizing');
6372
- handle.classList.remove('dragging');
6373
- document.body.style.userSelect = '';
6374
- document.removeEventListener('mousemove', onMove);
6375
- document.removeEventListener('mouseup', onUp);
6376
- localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
6377
- }
6378
6552
  }
6379
6553
 
6380
6554
  function loadPanelWidths() {
@@ -7328,6 +7502,7 @@ try {
7328
7502
  initSidebarResize();
7329
7503
  applyModalZoom();
7330
7504
  loadModalFullscreen();
7505
+ initModalResize();
7331
7506
  loadPanelWidths();
7332
7507
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
7333
7508
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
package/public/index.html CHANGED
@@ -285,7 +285,7 @@
285
285
 
286
286
  <!-- Message Detail Modal -->
287
287
  <div id="msg-detail-modal" class="modal-overlay" onclick="closeMsgDetailModal()">
288
- <div class="modal" onclick="event.stopPropagation()" style="max-width: 860px; width: 90vw; max-height: 90vh; display: flex; flex-direction: column;">
288
+ <div class="modal msg-detail-dialog" onclick="event.stopPropagation()">
289
289
  <div class="modal-header">
290
290
  <h3 class="modal-title" id="msg-detail-title">Message</h3>
291
291
  <div style="display:flex;gap:4px;align-items:center;">
package/public/style.css CHANGED
@@ -1172,9 +1172,8 @@ body::before {
1172
1172
  padding-right: 8px;
1173
1173
  }
1174
1174
 
1175
- .column-empty {
1176
- text-align: center;
1177
- padding: 32px 16px;
1175
+ .column-empty,
1176
+ .column-add {
1178
1177
  color: var(--text-muted);
1179
1178
  font-size: 12px;
1180
1179
  border: 1px dashed var(--border);
@@ -1182,13 +1181,62 @@ body::before {
1182
1181
  margin-top: 4px;
1183
1182
  }
1184
1183
 
1185
- .column-empty svg {
1184
+ .column-empty {
1185
+ text-align: center;
1186
+ padding: 32px 16px;
1187
+ }
1188
+
1189
+ .column-add {
1190
+ display: flex;
1191
+ align-items: center;
1192
+ justify-content: center;
1193
+ gap: 6px;
1194
+ width: 100%;
1195
+ padding: 8px 16px;
1196
+ font-family: inherit;
1197
+ background: none;
1198
+ cursor: pointer;
1199
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1200
+ }
1201
+
1202
+ .column-add.empty {
1203
+ flex-direction: column;
1204
+ padding: 32px 16px;
1205
+ }
1206
+
1207
+ .column-add:hover,
1208
+ .column-add:focus-visible {
1209
+ outline: none;
1210
+ color: var(--text-primary);
1211
+ border-color: var(--text-muted);
1212
+ background: var(--bg-hover);
1213
+ }
1214
+
1215
+ .column-empty svg,
1216
+ .column-add.empty svg {
1186
1217
  width: 24px;
1187
1218
  height: 24px;
1188
- opacity: 0.5;
1189
1219
  margin-bottom: 8px;
1190
1220
  }
1191
1221
 
1222
+ .column-empty svg {
1223
+ opacity: 0.5;
1224
+ }
1225
+
1226
+ .column-add svg {
1227
+ width: 16px;
1228
+ height: 16px;
1229
+ opacity: 0.7;
1230
+ flex-shrink: 0;
1231
+ }
1232
+
1233
+ .column-add-input {
1234
+ margin-top: 4px;
1235
+ font-size: 12px;
1236
+ background: var(--bg-surface);
1237
+ border-color: var(--accent);
1238
+ }
1239
+
1192
1240
  /* #endregion */
1193
1241
 
1194
1242
  /* #region TASK_CARD */
@@ -3721,6 +3769,7 @@ body.light .msg-assistant .msg-text {
3721
3769
  }
3722
3770
 
3723
3771
  .modal {
3772
+ position: relative;
3724
3773
  background: var(--bg-surface);
3725
3774
  border: 1px solid var(--border);
3726
3775
  border-radius: 12px;
@@ -3734,6 +3783,16 @@ body.light .msg-assistant .msg-text {
3734
3783
  overflow-y: auto;
3735
3784
  }
3736
3785
 
3786
+ /* Before .modal.fullscreen on purpose: at equal specificity the later
3787
+ fullscreen rule must win, same as for every other per-modal size. */
3788
+ .modal.msg-detail-dialog {
3789
+ width: 90vw;
3790
+ max-width: 860px;
3791
+ max-height: 90vh;
3792
+ display: flex;
3793
+ flex-direction: column;
3794
+ }
3795
+
3737
3796
  /* Height is a cap, not a floor — a two-line message should not sit in a 92vh box.
3738
3797
  The HTML preview keeps its explicit height below: its iframe is `height: 100%`
3739
3798
  and needs a resolvable parent height. */
@@ -3743,6 +3802,39 @@ body.light .msg-assistant .msg-text {
3743
3802
  max-height: 92vh;
3744
3803
  }
3745
3804
 
3805
+ .modal-resize-handle {
3806
+ position: absolute;
3807
+ right: 0;
3808
+ bottom: 0;
3809
+ width: 18px;
3810
+ height: 18px;
3811
+ cursor: nwse-resize;
3812
+ z-index: 1;
3813
+ color: var(--text-muted);
3814
+ opacity: 0.55;
3815
+ border-bottom-right-radius: 11px;
3816
+ background: linear-gradient(
3817
+ 135deg,
3818
+ transparent 0 50%,
3819
+ currentColor 50% 56%,
3820
+ transparent 56% 64%,
3821
+ currentColor 64% 70%,
3822
+ transparent 70% 78%,
3823
+ currentColor 78% 84%,
3824
+ transparent 84%
3825
+ );
3826
+ }
3827
+
3828
+ .modal-resize-handle:hover,
3829
+ .modal-resize-handle.dragging {
3830
+ opacity: 1;
3831
+ color: var(--accent);
3832
+ }
3833
+
3834
+ .modal.fullscreen .modal-resize-handle {
3835
+ display: none;
3836
+ }
3837
+
3746
3838
  .modal.plan-modal {
3747
3839
  width: 60vw;
3748
3840
  max-width: 60vw;
@@ -3839,6 +3931,19 @@ body.light .msg-assistant .msg-text {
3839
3931
  max-height: 98vh;
3840
3932
  }
3841
3933
 
3934
+ /* User-dragged size (bottom-right grip, app.js initModalResize). Last of the
3935
+ modal sizing rules on purpose: at equal specificity, source order lets it
3936
+ beat every per-modal size above; fullscreen still wins via :not() while
3937
+ active, and JS keeps inline sizing off user-sized modals (autoSizeModal and
3938
+ "Show more" skip them, the drag clears leftovers). Height is a cap, not a
3939
+ floor (same rule as .fullscreen) — short content still collapses. min()
3940
+ keeps a size saved on a large screen inside the current viewport. */
3941
+ .modal.user-sized:not(.fullscreen) {
3942
+ width: min(var(--user-modal-w), 98vw);
3943
+ max-width: min(var(--user-modal-w), 98vw);
3944
+ max-height: min(var(--user-modal-h), 95vh);
3945
+ }
3946
+
3842
3947
  .preview-html .modal-body,
3843
3948
  .preview-html #preview-modal-body {
3844
3949
  flex: 1 1 auto;
package/server.js CHANGED
@@ -94,6 +94,7 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
94
94
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
95
95
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
96
96
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
97
+ const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
97
98
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
98
99
  const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
99
100
 
@@ -106,17 +107,27 @@ function readPins() {
106
107
  return {};
107
108
  }
108
109
 
109
- function writePins(pins) {
110
+ function writeJsonAtomic(file, obj) {
110
111
  try {
111
112
  mkdirSync(CCK_DIR, { recursive: true });
112
- const tmp = `${PINS_FILE}.${process.pid}.${Date.now()}.tmp`;
113
- writeFileSync(tmp, JSON.stringify(pins, null, 2), 'utf8');
114
- renameSync(tmp, PINS_FILE);
113
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
114
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
115
+ renameSync(tmp, file);
115
116
  } catch (e) {
116
- console.error('Failed to write pins.json:', e.message);
117
+ console.error(`Failed to write ${path.basename(file)}:`, e.message);
117
118
  }
118
119
  }
119
120
 
121
+ function writePins(pins) {
122
+ writeJsonAtomic(PINS_FILE, pins);
123
+ }
124
+
125
+ // Port discovery for out-of-process helpers (the postman monitor). The pid rides along
126
+ // so a reader can tell a live server from a file left behind by a crashed one.
127
+ function writeServerInfo(port) {
128
+ writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
129
+ }
130
+
120
131
  // #region TIMINGS
121
132
  const PERMISSION_TTL_MS = 30 * 60 * 1000;
122
133
  const AGENT_TTL_MS = 60 * 60 * 1000;
@@ -562,6 +573,13 @@ function getCustomTaskDir(sessionId) {
562
573
  return null;
563
574
  }
564
575
 
576
+ // Where a session's task files live. The custom-list and team lookups can both miss, and
577
+ // the fallback is the plain per-session dir -- every route that touches a task file needs
578
+ // that same resolution, so it lives in one place.
579
+ function taskDirFor(sessionId) {
580
+ return getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
581
+ }
582
+
565
583
  function getTaskCounts(sessionPath) {
566
584
  const cached = taskCountsCache.get(sessionPath);
567
585
  if (cached) return cached;
@@ -1409,8 +1427,7 @@ app.get('/api/projects', (req, res) => {
1409
1427
  // API: Get tasks for a session
1410
1428
  app.get('/api/sessions/:sessionId', async (req, res) => {
1411
1429
  try {
1412
- const customDir = getCustomTaskDir(req.params.sessionId);
1413
- const sessionPath = customDir || path.join(TASKS_DIR, req.params.sessionId);
1430
+ const sessionPath = taskDirFor(req.params.sessionId);
1414
1431
 
1415
1432
  if (!existsSync(sessionPath)) {
1416
1433
  return res.status(404).json({ error: 'Session not found' });
@@ -2636,13 +2653,54 @@ app.get('/api/tasks/all', async (req, res) => {
2636
2653
  }
2637
2654
  });
2638
2655
 
2656
+ const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents } = require('./lib/session-events');
2657
+ app.get('/api/sessions/:sessionId/events', handleSessionEvents);
2658
+
2659
+ // API: Create a task
2660
+ app.post('/api/tasks/:sessionId', async (req, res) => {
2661
+ try {
2662
+ const { sessionId } = req.params;
2663
+ const subject = (req.body.subject || '').trim();
2664
+ if (!subject) return res.status(400).json({ error: 'Subject is required' });
2665
+
2666
+ const sessionDir = taskDirFor(sessionId);
2667
+ if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
2668
+
2669
+ // Ids are the agent's own numbering scheme, so a hand-made task has to keep counting
2670
+ // from the highest one on disk -- reusing a number would overwrite that task's file.
2671
+ const ids = readdirSync(sessionDir)
2672
+ .filter((f) => f.endsWith('.json'))
2673
+ .map((f) => parseInt(path.basename(f, '.json'), 10))
2674
+ .filter((n) => Number.isInteger(n));
2675
+ const id = String(Math.max(0, ...ids) + 1);
2676
+
2677
+ const task = {
2678
+ id,
2679
+ subject,
2680
+ description: (req.body.description || '').trim(),
2681
+ activeForm: subject,
2682
+ status: 'pending',
2683
+ blocks: [],
2684
+ blockedBy: [],
2685
+ };
2686
+
2687
+ // No doorbell here, unlike a move: the user typed this task, so telling their session
2688
+ // about it would only repeat what they just said. Dragging it to In Progress rings.
2689
+ await fs.writeFile(path.join(sessionDir, `${id}.json`), JSON.stringify(task, null, 2));
2690
+ res.json({ success: true, task });
2691
+ } catch (error) {
2692
+ console.error('Error creating task:', error);
2693
+ res.status(500).json({ error: 'Failed to create task' });
2694
+ }
2695
+ });
2696
+
2639
2697
  // API: Update task fields (subject, description)
2640
2698
  app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2641
2699
  try {
2642
2700
  const { sessionId, taskId } = req.params;
2643
2701
  const { subject, description } = req.body;
2644
2702
 
2645
- const sessionDir = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
2703
+ const sessionDir = taskDirFor(sessionId);
2646
2704
  const taskPath = path.join(sessionDir, `${taskId}.json`);
2647
2705
 
2648
2706
  if (!existsSync(taskPath)) {
@@ -2650,6 +2708,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2650
2708
  }
2651
2709
 
2652
2710
  const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
2711
+ const prevStatus = task.status;
2653
2712
 
2654
2713
  if (subject !== undefined) task.subject = subject;
2655
2714
  if (description !== undefined) task.description = description;
@@ -2657,6 +2716,17 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2657
2716
 
2658
2717
  await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
2659
2718
 
2719
+ // Ring the session only for a move. The direction has to ride in the line because
2720
+ // the write above destroyed the old status -- nothing downstream can recover it, and
2721
+ // which way a task moved is what decides whether to start work or stop it.
2722
+ if (task.status !== prevStatus) {
2723
+ // The route param is a task *directory*, which for a shared list or team board is
2724
+ // not a session id -- and the postman polls with its own session id, so an unresolved
2725
+ // name would queue the line where nobody drains it.
2726
+ const line = formatTaskMoved(taskId, prevStatus, task);
2727
+ for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
2728
+ }
2729
+
2660
2730
  res.json({ success: true, task });
2661
2731
  } catch (error) {
2662
2732
  console.error('Error updating task:', error);
@@ -2668,7 +2738,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2668
2738
  app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2669
2739
  try {
2670
2740
  const { sessionId, taskId } = req.params;
2671
- const sessionPath = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
2741
+ const sessionPath = taskDirFor(sessionId);
2672
2742
  const taskPath = path.join(sessionPath, `${taskId}.json`);
2673
2743
 
2674
2744
  if (!existsSync(taskPath)) {
@@ -2964,27 +3034,27 @@ watcher.on('all', (event, filePath) => {
2964
3034
 
2965
3035
  taskCountsCache.delete(path.join(TASKS_DIR, dirName));
2966
3036
 
2967
- if (isUUID(dirName)) {
2968
- broadcast({ type: 'update', event, sessionId: dirName, file: path.basename(filePath) });
2969
- } else {
2970
- broadcastToMappedSessions(dirName, event, filePath);
2971
- }
3037
+ broadcastToMappedSessions(dirName, event, filePath);
2972
3038
  }
2973
3039
  });
2974
3040
 
2975
- function broadcastToMappedSessions(taskListName, event, filePath) {
3041
+ // Which sessions own a task directory. Usually the dir name IS the session id, but a
3042
+ // shared list or a team dir is one directory several sessions map onto -- so anything
3043
+ // addressing a session from a task path has to fan out the same way. One resolver for
3044
+ // both readers of that mapping: the SSE broadcast and the doorbell.
3045
+ function resolveSessionsForTaskDir(name) {
3046
+ if (isUUID(name)) return [name];
2976
3047
  const { listToSessions } = loadAllTaskMaps();
2977
- const map = listToSessions[taskListName];
2978
- if (map) {
2979
- for (const sid of Object.keys(map)) {
2980
- broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
2981
- }
2982
- return;
2983
- }
2984
- // Fallback: check if taskListName is a team name
2985
- const cfg = loadTeamConfig(taskListName);
2986
- if (cfg?.leadSessionId) {
2987
- broadcast({ type: 'update', event, sessionId: cfg.leadSessionId, file: path.basename(filePath) });
3048
+ const map = listToSessions[name];
3049
+ if (map) return Object.keys(map);
3050
+ // Fallback: check if name is a team name
3051
+ const cfg = loadTeamConfig(name);
3052
+ return cfg?.leadSessionId ? [cfg.leadSessionId] : [];
3053
+ }
3054
+
3055
+ function broadcastToMappedSessions(taskListName, event, filePath) {
3056
+ for (const sid of resolveSessionsForTaskDir(taskListName)) {
3057
+ broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
2988
3058
  }
2989
3059
  }
2990
3060
 
@@ -3213,6 +3283,9 @@ async function prewarmCaches() {
3213
3283
 
3214
3284
  const onReady = (actualPort) => {
3215
3285
  console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
3286
+ // The port is configurable and falls back to a random one when taken, so the postman
3287
+ // monitor cannot assume it -- publish the live one where it can read it.
3288
+ writeServerInfo(actualPort);
3216
3289
  const warning = net.exposureWarning();
3217
3290
  if (warning) console.log(warning);
3218
3291