claude-code-kanban 4.18.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.18.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() {
@@ -6453,6 +6627,14 @@ function updateStickyBtnState() {
6453
6627
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
6454
6628
  }
6455
6629
 
6630
+ // Purely cosmetic: the tmp root is identical for every session and the encoded
6631
+ // project segment is already shown in the Path row, so only the last two segments
6632
+ // carry information. The full path stays in the tooltip and the copy button.
6633
+ function abbreviateScratchpadDir(dir) {
6634
+ const sep = dir.includes('\\') ? '\\' : '/';
6635
+ return ['$tmp', '…', ...dir.split(/[/\\]/).slice(-2)].join(sep);
6636
+ }
6637
+
6456
6638
  function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6457
6639
  const modal = document.getElementById('team-modal');
6458
6640
  const titleEl = document.getElementById('team-modal-title');
@@ -6470,7 +6652,8 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6470
6652
  let html = '';
6471
6653
 
6472
6654
  // Session & project details as compact key-value rows
6473
- // Each row: [label, displayValue, { openPath?, copyValue? }]
6655
+ // Each row: [label, value, { openPath?, abbrev? }] — `value` is authoritative
6656
+ // (tooltip + copy); `abbrev` only replaces the rendered text.
6474
6657
  const infoRows = [];
6475
6658
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
6476
6659
  if (parentInfo?.parentSessionId) {
@@ -6500,6 +6683,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6500
6683
  if (session.tasksDir) {
6501
6684
  infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
6502
6685
  }
6686
+ if (session.scratchpadDir) {
6687
+ infoRows.push([
6688
+ 'Scratchpad',
6689
+ session.scratchpadDir,
6690
+ { openPath: session.scratchpadDir, abbrev: abbreviateScratchpadDir(session.scratchpadDir) },
6691
+ ]);
6692
+ }
6503
6693
  if (session.sharedTaskList) {
6504
6694
  infoRows.push(['Shared Tasks', session.sharedTaskList]);
6505
6695
  }
@@ -6517,7 +6707,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6517
6707
  if (opts?.openSession) {
6518
6708
  html += `<span onclick="openSessionFromInfo('${escAttrJs(opts.openSession)}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
6519
6709
  } else {
6520
- html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(value)}</span>`;
6710
+ html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(opts?.abbrev || value)}</span>`;
6521
6711
  }
6522
6712
  const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
6523
6713
  let openBtn = '';
@@ -7312,6 +7502,7 @@ try {
7312
7502
  initSidebarResize();
7313
7503
  applyModalZoom();
7314
7504
  loadModalFullscreen();
7505
+ initModalResize();
7315
7506
  loadPanelWidths();
7316
7507
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
7317
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 */
@@ -2805,6 +2853,9 @@ body::before {
2805
2853
  }
2806
2854
  .rendered-md blockquote {
2807
2855
  margin: 0.6em 0;
2856
+ padding-left: 10px;
2857
+ border-left: 2px solid var(--border);
2858
+ color: var(--text-secondary);
2808
2859
  }
2809
2860
  .rendered-md table {
2810
2861
  margin: 0.6em 0;
@@ -3718,6 +3769,7 @@ body.light .msg-assistant .msg-text {
3718
3769
  }
3719
3770
 
3720
3771
  .modal {
3772
+ position: relative;
3721
3773
  background: var(--bg-surface);
3722
3774
  border: 1px solid var(--border);
3723
3775
  border-radius: 12px;
@@ -3731,6 +3783,16 @@ body.light .msg-assistant .msg-text {
3731
3783
  overflow-y: auto;
3732
3784
  }
3733
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
+
3734
3796
  /* Height is a cap, not a floor — a two-line message should not sit in a 92vh box.
3735
3797
  The HTML preview keeps its explicit height below: its iframe is `height: 100%`
3736
3798
  and needs a resolvable parent height. */
@@ -3740,6 +3802,39 @@ body.light .msg-assistant .msg-text {
3740
3802
  max-height: 92vh;
3741
3803
  }
3742
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
+
3743
3838
  .modal.plan-modal {
3744
3839
  width: 60vw;
3745
3840
  max-width: 60vw;
@@ -3836,6 +3931,19 @@ body.light .msg-assistant .msg-text {
3836
3931
  max-height: 98vh;
3837
3932
  }
3838
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
+
3839
3947
  .preview-html .modal-body,
3840
3948
  .preview-html #preview-modal-body {
3841
3949
  flex: 1 1 auto;
package/server.js CHANGED
@@ -94,6 +94,9 @@ 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');
98
+ // Harness-owned scratchpad root; the per-session dir under it is created lazily.
99
+ const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
97
100
 
98
101
  // Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
99
102
  function readPins() {
@@ -104,17 +107,27 @@ function readPins() {
104
107
  return {};
105
108
  }
106
109
 
107
- function writePins(pins) {
110
+ function writeJsonAtomic(file, obj) {
108
111
  try {
109
112
  mkdirSync(CCK_DIR, { recursive: true });
110
- const tmp = `${PINS_FILE}.${process.pid}.${Date.now()}.tmp`;
111
- writeFileSync(tmp, JSON.stringify(pins, null, 2), 'utf8');
112
- 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);
113
116
  } catch (e) {
114
- console.error('Failed to write pins.json:', e.message);
117
+ console.error(`Failed to write ${path.basename(file)}:`, e.message);
115
118
  }
116
119
  }
117
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
+
118
131
  // #region TIMINGS
119
132
  const PERMISSION_TTL_MS = 30 * 60 * 1000;
120
133
  const AGENT_TTL_MS = 60 * 60 * 1000;
@@ -560,6 +573,13 @@ function getCustomTaskDir(sessionId) {
560
573
  return null;
561
574
  }
562
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
+
563
583
  function getTaskCounts(sessionPath) {
564
584
  const cached = taskCountsCache.get(sessionPath);
565
585
  if (cached) return cached;
@@ -961,6 +981,14 @@ function getSessionDisplayName(sessionId, meta) {
961
981
  return null;
962
982
  }
963
983
 
984
+ // Derived by convention, not looked up: the harness creates the dir lazily, so a
985
+ // stat here would report "missing" for every session that has not written a temp
986
+ // file yet — and it would put IO on the session-list hot path. Pure string join.
987
+ function getScratchpadDir(id, meta) {
988
+ if (!meta.jsonlPath) return null;
989
+ return path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
990
+ }
991
+
964
992
  function buildSessionObject(id, meta, overrides = {}) {
965
993
  const logStat = overrides._logStat || getSessionLogStat(meta);
966
994
  const logMtime = logStat.mtime;
@@ -992,6 +1020,7 @@ function buildSessionObject(id, meta, overrides = {}) {
992
1020
  jsonlPath: meta.jsonlPath || null,
993
1021
  tasksDir: null,
994
1022
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
1023
+ scratchpadDir: getScratchpadDir(id, meta),
995
1024
  contextStatus: getContextStatus(id, meta),
996
1025
  ...getPlanInfo(meta.slug),
997
1026
  ...getWorkflowInfoSummary(id),
@@ -1398,8 +1427,7 @@ app.get('/api/projects', (req, res) => {
1398
1427
  // API: Get tasks for a session
1399
1428
  app.get('/api/sessions/:sessionId', async (req, res) => {
1400
1429
  try {
1401
- const customDir = getCustomTaskDir(req.params.sessionId);
1402
- const sessionPath = customDir || path.join(TASKS_DIR, req.params.sessionId);
1430
+ const sessionPath = taskDirFor(req.params.sessionId);
1403
1431
 
1404
1432
  if (!existsSync(sessionPath)) {
1405
1433
  return res.status(404).json({ error: 'Session not found' });
@@ -2625,13 +2653,54 @@ app.get('/api/tasks/all', async (req, res) => {
2625
2653
  }
2626
2654
  });
2627
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
+
2628
2697
  // API: Update task fields (subject, description)
2629
2698
  app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2630
2699
  try {
2631
2700
  const { sessionId, taskId } = req.params;
2632
2701
  const { subject, description } = req.body;
2633
2702
 
2634
- const sessionDir = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
2703
+ const sessionDir = taskDirFor(sessionId);
2635
2704
  const taskPath = path.join(sessionDir, `${taskId}.json`);
2636
2705
 
2637
2706
  if (!existsSync(taskPath)) {
@@ -2639,6 +2708,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2639
2708
  }
2640
2709
 
2641
2710
  const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
2711
+ const prevStatus = task.status;
2642
2712
 
2643
2713
  if (subject !== undefined) task.subject = subject;
2644
2714
  if (description !== undefined) task.description = description;
@@ -2646,6 +2716,17 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2646
2716
 
2647
2717
  await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
2648
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
+
2649
2730
  res.json({ success: true, task });
2650
2731
  } catch (error) {
2651
2732
  console.error('Error updating task:', error);
@@ -2657,7 +2738,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2657
2738
  app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2658
2739
  try {
2659
2740
  const { sessionId, taskId } = req.params;
2660
- const sessionPath = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
2741
+ const sessionPath = taskDirFor(sessionId);
2661
2742
  const taskPath = path.join(sessionPath, `${taskId}.json`);
2662
2743
 
2663
2744
  if (!existsSync(taskPath)) {
@@ -2953,27 +3034,27 @@ watcher.on('all', (event, filePath) => {
2953
3034
 
2954
3035
  taskCountsCache.delete(path.join(TASKS_DIR, dirName));
2955
3036
 
2956
- if (isUUID(dirName)) {
2957
- broadcast({ type: 'update', event, sessionId: dirName, file: path.basename(filePath) });
2958
- } else {
2959
- broadcastToMappedSessions(dirName, event, filePath);
2960
- }
3037
+ broadcastToMappedSessions(dirName, event, filePath);
2961
3038
  }
2962
3039
  });
2963
3040
 
2964
- 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];
2965
3047
  const { listToSessions } = loadAllTaskMaps();
2966
- const map = listToSessions[taskListName];
2967
- if (map) {
2968
- for (const sid of Object.keys(map)) {
2969
- broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
2970
- }
2971
- return;
2972
- }
2973
- // Fallback: check if taskListName is a team name
2974
- const cfg = loadTeamConfig(taskListName);
2975
- if (cfg?.leadSessionId) {
2976
- 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) });
2977
3058
  }
2978
3059
  }
2979
3060
 
@@ -3202,6 +3283,9 @@ async function prewarmCaches() {
3202
3283
 
3203
3284
  const onReady = (actualPort) => {
3204
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);
3205
3289
  const warning = net.exposureWarning();
3206
3290
  if (warning) console.log(warning);
3207
3291