@luuthuong/devboard 0.0.0-stage → 1.0.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.
Files changed (79) hide show
  1. package/.claude/skills/devboard/SKILL.md +111 -0
  2. package/bin/devboard.mjs +59 -0
  3. package/dist/assets/CardDetailView-CVWOsYyA.js +24 -0
  4. package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
  5. package/dist/assets/DiffPane-bQsGoT7y.js +22 -0
  6. package/dist/assets/KnowledgeView-C5KHiBRG.js +3 -0
  7. package/dist/assets/ListBoardView-CzJcCLXS.js +3 -0
  8. package/dist/assets/NewTaskDialog-BuxvfgNU.js +1 -0
  9. package/dist/assets/OnboardingWizard-8WLFjTd8.js +1 -0
  10. package/dist/assets/ProcessManagerView-BIYjQpoP.js +1 -0
  11. package/dist/assets/ProposalCard-BbPobl1f.js +1 -0
  12. package/dist/assets/SearchOverlay-B4aywpIh.js +1 -0
  13. package/dist/assets/SettingsView-spBB3EwB.js +1 -0
  14. package/dist/assets/SetupProjectView-BuUDqDYU.js +1 -0
  15. package/dist/assets/Surface-BQKeigoq.js +1 -0
  16. package/dist/assets/TaskDescriptionEditor-Cv0nlU3w.js +58 -0
  17. package/dist/assets/Terminal-BrP-ENHg.css +1 -0
  18. package/dist/assets/Terminal-DJJcg-t6.js +80 -0
  19. package/dist/assets/chevron-right-BsVThPz4.js +1 -0
  20. package/dist/assets/index-DNKB6XwG.css +1 -0
  21. package/dist/assets/index-gWSenOqD.js +18 -0
  22. package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
  23. package/dist/assets/plus-XSPxArj5.js +1 -0
  24. package/dist/assets/react-dom-DjtJUrx3.js +1 -0
  25. package/dist/assets/x-CdEDbLMR.js +1 -0
  26. package/dist/index.html +22 -0
  27. package/package.json +81 -4
  28. package/server-dist/server/assets/remote-shell.sh +239 -0
  29. package/server-dist/server/board.js +486 -0
  30. package/server-dist/server/commands.js +424 -0
  31. package/server-dist/server/context.js +34 -0
  32. package/server-dist/server/coordination.js +442 -0
  33. package/server-dist/server/editor.js +101 -0
  34. package/server-dist/server/events.js +17 -0
  35. package/server-dist/server/git.js +258 -0
  36. package/server-dist/server/harness/config.js +90 -0
  37. package/server-dist/server/harness/entry.js +98 -0
  38. package/server-dist/server/harness/intents.js +268 -0
  39. package/server-dist/server/harness/kbIndex.js +291 -0
  40. package/server-dist/server/harness/kbStore.js +258 -0
  41. package/server-dist/server/harness/librarian.js +147 -0
  42. package/server-dist/server/harness/pack.js +182 -0
  43. package/server-dist/server/harness/proposals.js +113 -0
  44. package/server-dist/server/harness/repo.js +150 -0
  45. package/server-dist/server/harness/scrub.js +30 -0
  46. package/server-dist/server/harness/service.js +683 -0
  47. package/server-dist/server/harness/template/README.md +38 -0
  48. package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
  49. package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
  50. package/server-dist/server/harness/template/agents/test-writer.md +16 -0
  51. package/server-dist/server/harness/template/agents.yaml +41 -0
  52. package/server-dist/server/harness/template/harness.json +11 -0
  53. package/server-dist/server/harness/template/kb/team/README.md +4 -0
  54. package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
  55. package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
  56. package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
  57. package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
  58. package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
  59. package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
  60. package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
  61. package/server-dist/server/harness/usage.js +82 -0
  62. package/server-dist/server/hooks.js +222 -0
  63. package/server-dist/server/http.js +266 -0
  64. package/server-dist/server/index.js +77 -0
  65. package/server-dist/server/integrations.js +292 -0
  66. package/server-dist/server/pollers.js +332 -0
  67. package/server-dist/server/processes.js +123 -0
  68. package/server-dist/server/psmux.js +120 -0
  69. package/server-dist/server/pty.js +93 -0
  70. package/server-dist/server/remote.js +216 -0
  71. package/server-dist/server/sessionReaper.js +101 -0
  72. package/server-dist/server/sessions.js +315 -0
  73. package/server-dist/server/settings.js +125 -0
  74. package/server-dist/server/skill.js +29 -0
  75. package/server-dist/server/tmux.js +217 -0
  76. package/server-dist/server/transcripts.js +335 -0
  77. package/server-dist/server/util.js +286 -0
  78. package/server-dist/src/types/index.js +53 -0
  79. package/README.md +0 -3
@@ -0,0 +1,123 @@
1
+ // Process Manager data: tmux sessions, Claude processes, worktrees.
2
+ // Ports process_manager.rs. Everything is best-effort — a missing tool yields
3
+ // an empty list, not an error.
4
+ import { IS_WINDOWS, log, run } from "./util.js";
5
+ import { listWorktrees } from "./git.js";
6
+ import { psmuxPath } from "./psmux.js";
7
+ import * as tmux from "./tmux.js";
8
+ import path from "node:path";
9
+ /** `--session-id <id>` / `--resume <id>` (also `=`), only on a clean flag boundary. */
10
+ export function extractSessionId(command) {
11
+ for (const flag of ["--session-id", "--resume"]) {
12
+ let from = 0;
13
+ for (;;) {
14
+ const rel = command.indexOf(flag, from);
15
+ if (rel < 0)
16
+ break;
17
+ const after = command.slice(rel + flag.length);
18
+ const next = after[0];
19
+ if (next !== undefined && next !== "=" && next !== " " && next !== "\t") {
20
+ from = rel + flag.length;
21
+ continue;
22
+ }
23
+ let rest = after.trimStart();
24
+ if (rest.startsWith("="))
25
+ rest = rest.slice(1);
26
+ const token = rest.split(/\s/)[0];
27
+ if (token)
28
+ return token;
29
+ from = rel + flag.length;
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+ /** First argv token must be a Claude binary (or node running a claude cli.js). */
35
+ export function isClaudeCommandLine(command) {
36
+ const argv0 = command.trim().split(/\s+/)[0] ?? "";
37
+ const exe = path.basename(argv0);
38
+ if (["claude", "claude.exe", "claude.cmd", "claude.bat"].includes(exe))
39
+ return true;
40
+ return (exe === "node" || exe === "node.exe") && command.includes("/claude/") && command.includes("cli.js");
41
+ }
42
+ async function windowsListClaude() {
43
+ // CIM gives PID + full CommandLine in one shot. The filter is deliberately
44
+ // narrow so an editor with "claude" in its argv can't be listed (and killed).
45
+ const script = `
46
+ Get-CimInstance Win32_Process |
47
+ Where-Object {
48
+ $_.Name -match '^claude(\\.exe|\\.cmd|\\.bat)?$' -or
49
+ ($_.Name -match '^node(\\.exe)?$' -and $_.CommandLine -match '[\\\\\\/]claude[\\\\\\/].*cli\\.js')
50
+ } |
51
+ ForEach-Object { "$($_.ProcessId)\`t$($_.CommandLine)" }`;
52
+ const r = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
53
+ if (!r.ok)
54
+ return [];
55
+ const out = [];
56
+ for (const line of r.stdout.split(/\r?\n/)) {
57
+ const tab = line.indexOf("\t");
58
+ if (tab < 0)
59
+ continue;
60
+ const pid = Number.parseInt(line.slice(0, tab).trim(), 10);
61
+ const command = line.slice(tab + 1).trim();
62
+ if (Number.isNaN(pid) || !command)
63
+ continue;
64
+ out.push({ pid, command, sessionId: extractSessionId(command) });
65
+ }
66
+ return out;
67
+ }
68
+ async function unixListClaude() {
69
+ const r = await run("ps", ["-eo", "pid,command"]);
70
+ if (!r.ok)
71
+ return [];
72
+ const out = [];
73
+ for (const line of r.stdout.split(/\r?\n/)) {
74
+ const m = line.trimStart().match(/^(\d+)\s+(.*)$/);
75
+ if (!m)
76
+ continue;
77
+ const command = m[2].trim();
78
+ if (!isClaudeCommandLine(command))
79
+ continue;
80
+ out.push({ pid: Number(m[1]), command, sessionId: extractSessionId(command) });
81
+ }
82
+ return out;
83
+ }
84
+ export const listClaudeProcesses = () => (IS_WINDOWS ? windowsListClaude() : unixListClaude());
85
+ export async function killClaudeProcess(pid) {
86
+ if (!Number.isInteger(pid) || pid <= 0)
87
+ throw new Error("invalid pid");
88
+ const r = IS_WINDOWS ? await run("taskkill", ["/PID", String(pid), "/F"]) : await run("kill", ["-9", String(pid)]);
89
+ if (!r.ok)
90
+ throw new Error(r.stderr.trim() || `kill exited ${r.code}`);
91
+ log.info("process_manager", `killed claude pid=${pid}`);
92
+ }
93
+ export async function listAllWorktrees(repoRoots) {
94
+ const rows = [];
95
+ for (const root of repoRoots) {
96
+ try {
97
+ const projectName = path.basename(root.replace(/[\\/]+$/, "")) || root;
98
+ for (const w of await listWorktrees(root)) {
99
+ rows.push({ repoRoot: root, projectName, path: w.path, branch: w.branch, isMain: w.isMain });
100
+ }
101
+ }
102
+ catch (e) {
103
+ log.warn("process_manager", `list_worktrees(${root}) failed: ${e}`);
104
+ }
105
+ }
106
+ return rows;
107
+ }
108
+ export async function getProcessState(repoRoots) {
109
+ const [tmuxSessions, claudeProcesses, worktrees] = await Promise.all([
110
+ listAllTmuxSessions(),
111
+ listClaudeProcesses().catch(() => []),
112
+ listAllWorktrees(repoRoots),
113
+ ]);
114
+ return { tmuxSessions, claudeProcesses, worktrees };
115
+ }
116
+ /** Sessions from every multiplexer in play: psmux when installed, tmux in WSL (or native tmux elsewhere). */
117
+ async function listAllTmuxSessions() {
118
+ const [psmux, native] = await Promise.all([
119
+ psmuxPath() ? tmux.listSessions("psmux").catch(() => []) : [],
120
+ tmux.listSessions("wsl").catch(() => []),
121
+ ]);
122
+ return [...psmux, ...native];
123
+ }
@@ -0,0 +1,120 @@
1
+ // psmux (native Windows tmux) discovery + one-click install via winget.
2
+ //
3
+ // The installer is deliberately not parameterised: the client can only say
4
+ // "install", never choose a package or flags, so the endpoint can't be used to
5
+ // run arbitrary commands.
6
+ import { spawn } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { emit } from "./events.js";
10
+ import { IS_WINDOWS, errorMessage, findOnPath, log, run } from "./util.js";
11
+ const WINGET_ID = "marlocarlo.psmux";
12
+ let cachedPath = null;
13
+ let installing = false;
14
+ /** winget edits the user PATH, which a running server never sees — look where it puts the binary. */
15
+ function wingetCandidates() {
16
+ const local = process.env.LOCALAPPDATA;
17
+ if (!local)
18
+ return [];
19
+ const out = [path.join(local, "Microsoft", "WinGet", "Links", "psmux.exe")];
20
+ const pkgs = path.join(local, "Microsoft", "WinGet", "Packages");
21
+ try {
22
+ for (const d of fs.readdirSync(pkgs)) {
23
+ if (d.toLowerCase().startsWith("marlocarlo.psmux_"))
24
+ out.push(path.join(pkgs, d, "psmux.exe"));
25
+ }
26
+ }
27
+ catch {
28
+ // winget never ran
29
+ }
30
+ return out;
31
+ }
32
+ const isFile = (p) => {
33
+ try {
34
+ return fs.statSync(p).isFile();
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ };
40
+ /** Absolute path of psmux.exe, or null. */
41
+ export function psmuxPath() {
42
+ if (!IS_WINDOWS)
43
+ return null;
44
+ if (cachedPath && isFile(cachedPath))
45
+ return cachedPath;
46
+ cachedPath = findOnPath("psmux") ?? wingetCandidates().find(isFile) ?? null;
47
+ return cachedPath;
48
+ }
49
+ export async function psmuxStatus() {
50
+ if (!IS_WINDOWS)
51
+ return { supported: false, available: false, canInstall: false, installing: false };
52
+ const p = psmuxPath();
53
+ let version;
54
+ if (p) {
55
+ const r = await run(p, ["-V"], { timeoutMs: 5_000 });
56
+ if (r.ok)
57
+ version = r.stdout
58
+ .split(/\r?\n/)
59
+ .find((l) => l.trim())
60
+ ?.trim();
61
+ }
62
+ return {
63
+ supported: true,
64
+ available: !!p && !!version,
65
+ version,
66
+ path: p ?? undefined,
67
+ canInstall: await wingetAvailable(),
68
+ installing,
69
+ };
70
+ }
71
+ /** winget redraws its spinner with \r and backspaces; keep only readable lines. */
72
+ function cleanLines(chunk) {
73
+ return chunk
74
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")
75
+ .split(/[\r\n]+/)
76
+ .map((l) => l.replace(/[\b]/g, "").trim())
77
+ .filter((l) => l && !/^[-\\|/]$/.test(l));
78
+ }
79
+ /** Install psmux with winget, streaming `psmux-install-progress` events. One install at a time. */
80
+ export async function installPsmux() {
81
+ if (!IS_WINDOWS)
82
+ throw new Error("psmux is only available on Windows");
83
+ if (installing)
84
+ throw new Error("psmux install already in progress");
85
+ if (!(await wingetAvailable())) {
86
+ throw new Error("winget was not found — install psmux manually: winget install psmux");
87
+ }
88
+ installing = true;
89
+ const progress = (line) => emit("psmux-install-progress", { line });
90
+ try {
91
+ log.info("psmux", "installing via winget");
92
+ const code = await new Promise((resolve, reject) => {
93
+ const child = spawn("winget", ["install", "--id", WINGET_ID, "-e", "--silent", "--accept-source-agreements", "--accept-package-agreements"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
94
+ const onData = (d) => cleanLines(d.toString("utf8")).forEach(progress);
95
+ child.stdout?.on("data", onData);
96
+ child.stderr?.on("data", onData);
97
+ child.on("error", reject);
98
+ child.on("close", resolve);
99
+ });
100
+ cachedPath = null;
101
+ const status = await psmuxStatus();
102
+ // winget exits non-zero when the package is already installed; trust the binary instead.
103
+ if (!status.available)
104
+ throw new Error(`winget install failed (exit ${code})`);
105
+ log.info("psmux", `installed ${status.version ?? ""} at ${status.path}`);
106
+ return status;
107
+ }
108
+ catch (e) {
109
+ log.warn("psmux", `install failed: ${errorMessage(e)}`);
110
+ throw e;
111
+ }
112
+ finally {
113
+ installing = false;
114
+ emit("psmux-install-progress", { done: true });
115
+ }
116
+ }
117
+ /** winget is an app-execution alias (a reparse point), which `statSync` can't see — ask it directly. */
118
+ async function wingetAvailable() {
119
+ return (await run("winget", ["--version"], { timeoutMs: 10_000 })).ok;
120
+ }
@@ -0,0 +1,93 @@
1
+ // Terminal sessions over WebSocket (replaces tauri-pty). One PTY per socket:
2
+ // the browser sends {type:"spawn"} first, then input/resize; the server streams
3
+ // output and reports exit. Closing the socket kills the PTY — durable sessions
4
+ // live in tmux, which the PTY merely attaches to.
5
+ import * as nodePty from "@lydell/node-pty";
6
+ import { log } from "./util.js";
7
+ const clamp = (n, fallback, max) => typeof n === "number" && Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), 1), max) : fallback;
8
+ export function handlePtySocket(ws) {
9
+ let pty = null;
10
+ // node-pty on Windows (ConPTY) corrupts the native heap if a PTY is killed
11
+ // twice or touched after kill(), taking the whole server down. The browser
12
+ // sends {type:"kill"} and then closes the socket, so kill exactly once.
13
+ let killed = false;
14
+ const killPty = () => {
15
+ if (!pty || killed)
16
+ return;
17
+ killed = true;
18
+ try {
19
+ pty.kill();
20
+ }
21
+ catch {
22
+ // already gone
23
+ }
24
+ };
25
+ const send = (obj) => {
26
+ if (ws.readyState === ws.OPEN)
27
+ ws.send(JSON.stringify(obj));
28
+ };
29
+ ws.on("message", (raw) => {
30
+ let msg;
31
+ try {
32
+ msg = JSON.parse(raw.toString());
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ switch (msg.type) {
38
+ case "spawn": {
39
+ if (pty || killed)
40
+ return; // one PTY per socket
41
+ if (typeof msg.file !== "string" || !msg.file) {
42
+ send({ type: "error", message: "spawn: missing file" });
43
+ return;
44
+ }
45
+ try {
46
+ pty = nodePty.spawn(msg.file, Array.isArray(msg.args) ? msg.args.map(String) : [], {
47
+ name: "xterm-256color",
48
+ cols: clamp(msg.cols, 80, 1000),
49
+ rows: clamp(msg.rows, 24, 1000),
50
+ cwd: msg.cwd || undefined,
51
+ env: { ...process.env, TERM: "xterm-256color" },
52
+ });
53
+ }
54
+ catch (e) {
55
+ log.warn("pty", `spawn ${msg.file} failed: ${e}`);
56
+ send({ type: "error", message: String(e) });
57
+ return;
58
+ }
59
+ const p = pty;
60
+ p.onData((data) => send({ type: "data", data }));
61
+ p.onExit(({ exitCode, signal }) => {
62
+ send({ type: "exit", exitCode, signal });
63
+ pty = null;
64
+ });
65
+ send({ type: "spawned", pid: p.pid });
66
+ break;
67
+ }
68
+ case "data":
69
+ if (pty && !killed && typeof msg.data === "string")
70
+ pty.write(msg.data);
71
+ break;
72
+ case "resize":
73
+ if (pty && !killed) {
74
+ try {
75
+ pty.resize(clamp(msg.cols, 80, 1000), clamp(msg.rows, 24, 1000));
76
+ }
77
+ catch {
78
+ // resize after exit
79
+ }
80
+ }
81
+ break;
82
+ case "kill":
83
+ killPty();
84
+ break;
85
+ }
86
+ });
87
+ const cleanup = () => {
88
+ killPty();
89
+ pty = null;
90
+ };
91
+ ws.on("close", cleanup);
92
+ ws.on("error", cleanup);
93
+ }
@@ -0,0 +1,216 @@
1
+ // Remote execution: the remote-shell wrapper, SSH status files and the Mutagen
2
+ // sync adapter. Ports remote_shell.rs, remote_status.rs and mutagen.rs.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { IS_WINDOWS, findOnPath, devboardDataDir, run } from "./util.js";
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ /** Git-for-Windows bash: PATH first, then the two installer locations. */
9
+ export function findBash() {
10
+ const onPath = findOnPath("bash");
11
+ if (onPath)
12
+ return onPath;
13
+ if (!IS_WINDOWS)
14
+ return null;
15
+ return ([String.raw `C:\Program Files\Git\bin\bash.exe`, String.raw `C:\Program Files (x86)\Git\bin\bash.exe`].find((p) => fs.existsSync(p)) ?? null);
16
+ }
17
+ export const findMutagen = () => findOnPath("mutagen");
18
+ export function prereqs() {
19
+ const mutagenPath = findMutagen();
20
+ const bashPath = findBash();
21
+ return {
22
+ mutagenAvailable: !!mutagenPath,
23
+ bashAvailable: !!bashPath,
24
+ sshAvailable: !!findOnPath("ssh"),
25
+ mutagenPath: mutagenPath ?? undefined,
26
+ bashPath: bashPath ?? undefined,
27
+ };
28
+ }
29
+ // ── Remote shell wrapper ─────────────────────────────────────────────────────
30
+ const remoteDir = () => path.join(devboardDataDir(), "remote");
31
+ /** `C:\Users\foo` → `/c/Users/foo` (Git-for-Windows bash form). */
32
+ export function toPosixPath(win) {
33
+ const n = win.replaceAll("\\", "/");
34
+ return /^[A-Za-z]:/.test(n) ? `/${n[0].toLowerCase()}${n.slice(2)}` : n;
35
+ }
36
+ function renderScript() {
37
+ const template = fs.readFileSync(path.join(here, "assets", "remote-shell.sh"), "utf8");
38
+ return template
39
+ .replace("@@CONFIG_FILE@@", toPosixPath(path.join(devboardDataDir(), "settings.json")))
40
+ .replace("@@STATUS_DIR@@", toPosixPath(remoteDir()));
41
+ }
42
+ /** Write the wrapper plus same-content `bash`/`zsh` copies (assistants that
43
+ * hardcode either shell name pick up the wrapper). Idempotent. */
44
+ export async function deployRemoteShell() {
45
+ const dir = remoteDir();
46
+ await fs.promises.mkdir(dir, { recursive: true });
47
+ const script = renderScript();
48
+ for (const name of ["remote-shell.sh", "bash", "zsh"]) {
49
+ await fs.promises.writeFile(path.join(dir, name), script, { mode: 0o755 });
50
+ }
51
+ }
52
+ /** Polls `remote/status-<host>.json` files written by the wrapper script. */
53
+ export class RemoteStatusWatcher {
54
+ stateDir = remoteDir();
55
+ known = new Map();
56
+ /** Changes since the last poll. First sight of an offline host counts. */
57
+ async poll() {
58
+ let names;
59
+ try {
60
+ names = await fs.promises.readdir(this.stateDir);
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ const changes = [];
66
+ for (const name of names) {
67
+ if (!name.startsWith("status-") || !name.endsWith(".json"))
68
+ continue;
69
+ const host = name.slice("status-".length, -".json".length);
70
+ if (!host)
71
+ continue;
72
+ let json;
73
+ try {
74
+ json = JSON.parse(await fs.promises.readFile(path.join(this.stateDir, name), "utf8"));
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ const status = json?.status;
80
+ if (typeof status !== "string")
81
+ continue;
82
+ const prev = this.known.get(host);
83
+ this.known.set(host, status);
84
+ const changed = prev === undefined ? status === "offline" : prev !== status;
85
+ if (changed) {
86
+ changes.push({
87
+ host,
88
+ online: status === "online",
89
+ since: typeof json.since === "string" ? json.since : undefined,
90
+ });
91
+ }
92
+ }
93
+ return changes;
94
+ }
95
+ }
96
+ // ── Mutagen ──────────────────────────────────────────────────────────────────
97
+ export const defaultIgnores = () => [
98
+ "node_modules",
99
+ ".venv",
100
+ ".cache",
101
+ "dist",
102
+ ".next*",
103
+ "__pycache__",
104
+ ".pytest_cache",
105
+ ".mypy_cache",
106
+ ".turbo",
107
+ "*.pyc",
108
+ ".DS_Store",
109
+ "coverage",
110
+ ".nyc_output",
111
+ "target",
112
+ "build",
113
+ ".build",
114
+ ".swiftpm",
115
+ ];
116
+ const LABEL = "devboard=true";
117
+ const SESSION_NAME = "devboard-sync";
118
+ export const disabledStatus = () => ({ kind: "disabled", conflictCount: 0 });
119
+ async function mutagen(args) {
120
+ const bin = findMutagen();
121
+ if (!bin)
122
+ throw new Error("mutagen not on PATH");
123
+ return run(bin, args);
124
+ }
125
+ export async function ensureDaemon() {
126
+ const bin = findMutagen();
127
+ if (bin)
128
+ await run(bin, ["daemon", "start"]);
129
+ }
130
+ export async function startSync(localPath, host, remotePath, ignores) {
131
+ const list = await mutagen(["sync", "list", "--label-selector", LABEL]).catch(() => null);
132
+ if (list?.ok && list.stdout.includes("Name:")) {
133
+ await flushSync().catch(() => { });
134
+ return;
135
+ }
136
+ const args = [
137
+ "sync",
138
+ "create",
139
+ localPath,
140
+ `${host}:${remotePath}`,
141
+ "--name",
142
+ SESSION_NAME,
143
+ "--label",
144
+ LABEL,
145
+ "--sync-mode",
146
+ "two-way-resolved",
147
+ "--default-file-mode-beta",
148
+ "0644",
149
+ "--default-directory-mode-beta",
150
+ "0755",
151
+ ...ignores.flatMap((p) => ["--ignore", p]),
152
+ ];
153
+ const r = await mutagen(args);
154
+ if (!r.ok)
155
+ throw new Error(`mutagen sync create failed: ${r.stderr}`);
156
+ }
157
+ export async function stopSync() {
158
+ const r = await mutagen(["sync", "terminate", "--label-selector", LABEL]);
159
+ if (!r.ok)
160
+ throw new Error(`mutagen sync terminate failed: ${r.stderr}`);
161
+ }
162
+ export async function resetSync() {
163
+ await mutagen(["sync", "pause", "--label-selector", LABEL]).catch(() => { });
164
+ const r = await mutagen(["sync", "resume", "--label-selector", LABEL]);
165
+ if (!r.ok)
166
+ throw new Error(`mutagen sync resume failed: ${r.stderr}`);
167
+ }
168
+ export async function flushSync() {
169
+ const r = await mutagen(["sync", "flush", "--label-selector", LABEL]);
170
+ if (!r.ok)
171
+ throw new Error(`mutagen sync flush failed: ${r.stderr}`);
172
+ }
173
+ export async function syncStatus() {
174
+ if (!findMutagen())
175
+ return disabledStatus();
176
+ const template = "{{range .}}{{.Name}}|{{.Status}}|{{len .Conflicts}}|{{.Paused}}\n{{end}}";
177
+ const r = await mutagen(["sync", "list", "--label-selector", LABEL, "--template", template]).catch(() => null);
178
+ if (!r?.ok || !r.stdout.trim())
179
+ return disabledStatus();
180
+ for (const line of r.stdout.split(/\r?\n/)) {
181
+ const parts = line.split("|");
182
+ if (parts.length < 4)
183
+ continue;
184
+ const [name, statusRaw, conflictsRaw, pausedRaw] = [
185
+ parts[0],
186
+ parts[1].toLowerCase(),
187
+ parts[2],
188
+ parts.slice(3).join("|"),
189
+ ];
190
+ const conflicts = Number.parseInt(conflictsRaw, 10) || 0;
191
+ let kind;
192
+ if (pausedRaw.trim() === "true")
193
+ kind = "paused";
194
+ else if (conflicts > 0)
195
+ kind = "conflicts";
196
+ else if (statusRaw === "watching")
197
+ kind = "watching";
198
+ else if (statusRaw === "scanning")
199
+ kind = "scanning";
200
+ else if (["staging", "transitioning", "reconciling", "saving"].includes(statusRaw))
201
+ kind = "staging";
202
+ else if (statusRaw === "halted")
203
+ kind = "paused";
204
+ else
205
+ kind = "error";
206
+ return { kind, sessionName: name, conflictCount: conflicts, message: statusRaw };
207
+ }
208
+ return disabledStatus();
209
+ }
210
+ export async function rawSyncStatus() {
211
+ const r = await mutagen(["sync", "list", "-l"]);
212
+ const out = r.stdout.trim();
213
+ if (!r.ok && !out)
214
+ throw new Error(`mutagen sync list failed: ${r.stderr}`);
215
+ return out || "No sync sessions running.";
216
+ }
@@ -0,0 +1,101 @@
1
+ // Housekeeping for durable terminal sessions (psmux / tmux). Cards leave a
2
+ // `devboard-<cardId>` session behind when their drawer closes; this closes the
3
+ // ones nobody is using, per the user's Settings → Terminal sessions limits.
4
+ //
5
+ // A session is never touched while a client is attached or Claude is working.
6
+ import { psmuxPath } from "./psmux.js";
7
+ import * as tmux from "./tmux.js";
8
+ import { IS_WINDOWS, log } from "./util.js";
9
+ const PREFIX = "devboard-";
10
+ /** First/last time the reaper saw a session; stands in for "when the user detached". */
11
+ const firstSeen = new Map();
12
+ const lastAttached = new Map();
13
+ const key = (backend, name) => `${backend}:${name}`;
14
+ /** Session names for a card's drawer. */
15
+ export const cardSessionName = (cardId) => `${PREFIX}${cardId}`;
16
+ /** Close a card's terminal session; best-effort so deleting a card never fails on it. */
17
+ export async function killCardSession(ctx, cardId) {
18
+ const card = ctx.board.cards.find((c) => c.id === cardId);
19
+ const runtime = card?.link.cardRuntime;
20
+ const backend = runtime === "windows" ? "psmux" : "wsl";
21
+ // Don't boot WSL just to find out there's nothing to kill.
22
+ if (backend === "wsl" && IS_WINDOWS && runtime !== "wsl")
23
+ return;
24
+ try {
25
+ await tmux.killSession(cardSessionName(cardId), backend);
26
+ }
27
+ catch (e) {
28
+ log.warn("sessions", `kill ${cardSessionName(cardId)} failed: ${e}`);
29
+ }
30
+ }
31
+ async function collect(ctx) {
32
+ const backends = [];
33
+ if (psmuxPath())
34
+ backends.push("psmux");
35
+ // On Windows only look inside WSL when a card actually uses it.
36
+ if (!IS_WINDOWS || ctx.board.cards.some((c) => c.link.cardRuntime === "wsl"))
37
+ backends.push("wsl");
38
+ const now = Date.now();
39
+ const cardsById = new Map(ctx.board.cards.map((c) => [c.id, c]));
40
+ const out = [];
41
+ for (const backend of backends) {
42
+ for (const s of await tmux.listSessions(backend).catch(() => [])) {
43
+ if (!s.name.startsWith(PREFIX))
44
+ continue; // the user's own sessions are not ours to close
45
+ const k = key(backend, s.name);
46
+ if (!firstSeen.has(k))
47
+ firstSeen.set(k, now);
48
+ if (s.attached)
49
+ lastAttached.set(k, now);
50
+ const card = cardsById.get(s.name.slice(PREFIX.length));
51
+ const lastActivity = card?.link.lastActivity ? Date.parse(card.link.lastActivity) : 0;
52
+ out.push({
53
+ name: s.name,
54
+ backend,
55
+ idleSince: Math.max(Number.isNaN(lastActivity) ? 0 : lastActivity, s.createdAt * 1000, lastAttached.get(k) ?? 0, firstSeen.get(k) ?? now),
56
+ protectedNow: s.attached || card?.activityState === "activelyWorking",
57
+ });
58
+ }
59
+ }
60
+ // Forget sessions that are gone.
61
+ const live = new Set(out.map((c) => key(c.backend, c.name)));
62
+ for (const m of [firstSeen, lastAttached])
63
+ for (const k of m.keys())
64
+ if (!live.has(k))
65
+ m.delete(k);
66
+ return out;
67
+ }
68
+ export async function reapSessions(ctx) {
69
+ const cfg = (await ctx.settings.read()).terminalSessions ?? { idleMinutes: 30, maxSessions: 8 };
70
+ if (cfg.idleMinutes <= 0 && cfg.maxSessions <= 0)
71
+ return;
72
+ let sessions = await collect(ctx);
73
+ const now = Date.now();
74
+ const kill = async (c, why) => {
75
+ try {
76
+ await tmux.killSession(c.name, c.backend);
77
+ log.info("sessions", `closed ${c.name} (${why})`);
78
+ firstSeen.delete(key(c.backend, c.name));
79
+ lastAttached.delete(key(c.backend, c.name));
80
+ sessions = sessions.filter((s) => s !== c);
81
+ }
82
+ catch (e) {
83
+ log.warn("sessions", `close ${c.name} failed: ${e}`);
84
+ }
85
+ };
86
+ if (cfg.idleMinutes > 0) {
87
+ for (const c of [...sessions]) {
88
+ if (!c.protectedNow && now - c.idleSince > cfg.idleMinutes * 60_000) {
89
+ await kill(c, `idle ${Math.round((now - c.idleSince) / 60_000)} min`);
90
+ }
91
+ }
92
+ }
93
+ if (cfg.maxSessions > 0 && sessions.length > cfg.maxSessions) {
94
+ const evictable = sessions.filter((s) => !s.protectedNow).sort((a, b) => a.idleSince - b.idleSince);
95
+ for (const c of evictable) {
96
+ if (sessions.length <= cfg.maxSessions)
97
+ break;
98
+ await kill(c, `over the ${cfg.maxSessions}-session limit`);
99
+ }
100
+ }
101
+ }