@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,315 @@
1
+ // Session discovery for Claude Code.
2
+ // Ports jsonl_parser.rs and session_discovery.rs.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { IS_WINDOWS, log, readLines, run, takeChars } from "./util.js";
7
+ function extractTextContent(obj) {
8
+ const content = obj?.message?.content;
9
+ if (typeof content === "string")
10
+ return content;
11
+ if (Array.isArray(content)) {
12
+ const joined = content
13
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
14
+ .map((b) => b.text)
15
+ .join("\n");
16
+ if (joined)
17
+ return joined;
18
+ }
19
+ return undefined;
20
+ }
21
+ /** First few lines of a Claude .jsonl → metadata, or null when it has no messages. */
22
+ export async function extractMetadata(filePath) {
23
+ const meta = {
24
+ sessionId: path.basename(filePath, path.extname(filePath)),
25
+ messageCount: 0,
26
+ };
27
+ let foundFirstUser = false;
28
+ try {
29
+ for await (const line of readLines(filePath)) {
30
+ if (!line || !line.includes('"type"'))
31
+ continue;
32
+ let obj;
33
+ try {
34
+ obj = JSON.parse(line);
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ const msgType = typeof obj?.type === "string" ? obj.type : "";
40
+ if (!meta.projectPath && typeof obj.cwd === "string")
41
+ meta.projectPath = obj.cwd;
42
+ if (!meta.gitBranch && typeof obj.gitBranch === "string")
43
+ meta.gitBranch = obj.gitBranch;
44
+ if (msgType === "user" || msgType === "assistant")
45
+ meta.messageCount++;
46
+ if (msgType === "user" && !foundFirstUser) {
47
+ foundFirstUser = true;
48
+ meta.firstPrompt = extractTextContent(obj);
49
+ }
50
+ if (meta.messageCount >= 5 && foundFirstUser)
51
+ break;
52
+ }
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ return meta.messageCount === 0 ? null : meta;
58
+ }
59
+ /** "-Users-foo-bar" → "/Users/foo/bar". */
60
+ export function decodeDirectoryName(name) {
61
+ let result = name;
62
+ if (result.startsWith("-"))
63
+ result = "/" + result.slice(1);
64
+ return result.replaceAll("-", "/");
65
+ }
66
+ const PUSH_RE = /git\s+push\s+(?:-\S+\s+)*(?:origin|upstream)\s+(\S+)/g;
67
+ const CHECKOUT_RE = /git\s+checkout\s+-[bB]\s+(\S+)/g;
68
+ const SWITCH_RE = /git\s+switch\s+(?:-c|--create)\s+(\S+)/g;
69
+ const WORKTREE_RE = /git\s+worktree\s+add\s+\S+\s+-b\s+(\S+)/g;
70
+ /** Branches a session pushed/created, scraped from its Bash tool calls. */
71
+ export async function extractPushedBranches(filePath) {
72
+ const branches = new Set();
73
+ try {
74
+ for await (const line of readLines(filePath)) {
75
+ if (!line || !line.includes('"tool_use"'))
76
+ continue;
77
+ let obj;
78
+ try {
79
+ obj = JSON.parse(line);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ const content = obj?.message?.content;
85
+ if (!Array.isArray(content))
86
+ continue;
87
+ for (const block of content) {
88
+ if (block?.type !== "tool_use" || block?.name !== "Bash")
89
+ continue;
90
+ const command = block?.input?.command;
91
+ if (typeof command !== "string")
92
+ continue;
93
+ for (const re of [PUSH_RE, CHECKOUT_RE, SWITCH_RE, WORKTREE_RE]) {
94
+ for (const m of command.matchAll(re)) {
95
+ const b = m[1];
96
+ if (b !== "main" && b !== "master" && !b.startsWith("-"))
97
+ branches.add(b);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ return [...branches].sort();
107
+ }
108
+ // ── Claude directory resolution ──────────────────────────────────────────────
109
+ function isDir(p) {
110
+ try {
111
+ return fs.statSync(p).isDirectory();
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ function readdirSafe(p) {
118
+ try {
119
+ return fs.readdirSync(p);
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ }
125
+ function capitalize(s) {
126
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
127
+ }
128
+ /** Every Claude `projects` directory that exists: native, Claude Desktop, and
129
+ * WSL distros (via UNC paths, then a wsl.exe fallback). */
130
+ async function resolveAllClaudeDirs() {
131
+ const dirs = [];
132
+ const push = (p) => {
133
+ if (!dirs.includes(p))
134
+ dirs.push(p);
135
+ };
136
+ if (IS_WINDOWS) {
137
+ const profile = process.env.USERPROFILE;
138
+ if (profile) {
139
+ const p = path.join(profile, ".claude", "projects");
140
+ if (isDir(p))
141
+ push(p);
142
+ }
143
+ const appData = process.env.APPDATA;
144
+ if (appData) {
145
+ const p = path.join(appData, "Claude", "projects");
146
+ if (isDir(p))
147
+ push(p);
148
+ }
149
+ for (const wslRoot of ["\\\\wsl$", "\\\\wsl.localhost"]) {
150
+ for (const distro of readdirSafe(wslRoot)) {
151
+ const distroPath = path.join(wslRoot, distro);
152
+ for (const user of readdirSafe(path.join(distroPath, "home"))) {
153
+ const p = path.join(distroPath, "home", user, ".claude", "projects");
154
+ if (isDir(p))
155
+ push(p);
156
+ }
157
+ const rootP = path.join(distroPath, "root", ".claude", "projects");
158
+ if (isDir(rootP))
159
+ push(rootP);
160
+ }
161
+ }
162
+ // Fallback: ask wsl.exe for $HOME when UNC enumeration finds nothing.
163
+ const home = await run("wsl.exe", ["-e", "bash", "-c", "echo $HOME"], { timeoutMs: 8000 });
164
+ const wslHome = home.stdout.trim();
165
+ if (home.ok && wslHome) {
166
+ const distro = await run("wsl.exe", ["-e", "bash", "-c", "cat /etc/os-release | grep ^ID= | cut -d= -f2"], {
167
+ timeoutMs: 8000,
168
+ });
169
+ const id = distro.stdout.trim();
170
+ const candidates = [id, capitalize(id), `${id}-22.04`, "Ubuntu", "Ubuntu-22.04", "Ubuntu-24.04", "Debian"];
171
+ for (const wslRoot of ["\\\\wsl.localhost", "\\\\wsl$"]) {
172
+ for (const name of candidates) {
173
+ if (!name)
174
+ continue;
175
+ const p = path.join(wslRoot, name, wslHome.replace(/^\/+/, ""), ".claude", "projects");
176
+ if (isDir(p))
177
+ push(p);
178
+ }
179
+ }
180
+ }
181
+ }
182
+ else {
183
+ const p = path.join(os.homedir(), ".claude", "projects");
184
+ if (isDir(p))
185
+ push(p);
186
+ if (isWsl()) {
187
+ const p2 = wslClaudeDir();
188
+ if (p2)
189
+ push(p2);
190
+ }
191
+ }
192
+ if (dirs.length === 0)
193
+ dirs.push(path.join(os.homedir(), ".claude", "projects"));
194
+ return dirs;
195
+ }
196
+ export function isWsl() {
197
+ try {
198
+ return fs.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft");
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
204
+ function wslClaudeDir() {
205
+ const usersDir = "/mnt/c/Users";
206
+ if (!isDir(usersDir))
207
+ return null;
208
+ const profile = process.env.USERPROFILE;
209
+ if (profile) {
210
+ const p = path.join(profile, "AppData", "Roaming", "Claude", "projects");
211
+ if (isDir(p))
212
+ return p;
213
+ }
214
+ for (const user of readdirSafe(usersDir)) {
215
+ const p = path.join(usersDir, user, "AppData", "Roaming", "Claude", "projects");
216
+ if (isDir(p))
217
+ return p;
218
+ }
219
+ return null;
220
+ }
221
+ // ── mtime-keyed parse cache ──────────────────────────────────────────────────
222
+ // Discovery runs every few seconds over every transcript; re-parse only files
223
+ // whose mtime/size changed.
224
+ function cached() {
225
+ const map = new Map();
226
+ return async (file, st, parse) => {
227
+ const sig = `${st.mtimeMs}:${st.size}`;
228
+ const hit = map.get(file);
229
+ if (hit && hit.sig === sig)
230
+ return hit.value;
231
+ const value = await parse();
232
+ map.set(file, { sig, value });
233
+ return value;
234
+ };
235
+ }
236
+ const claudeMetaCache = cached();
237
+ // ── Discovery ────────────────────────────────────────────────────────────────
238
+ export class SessionDiscovery {
239
+ dirs;
240
+ constructor(claudeDir) {
241
+ this.dirs = claudeDir ? Promise.resolve([claudeDir]) : resolveAllClaudeDirs();
242
+ }
243
+ async discoverSessions() {
244
+ const byId = new Map();
245
+ for (const dir of await this.dirs) {
246
+ try {
247
+ await this.scanDirectory(dir, byId);
248
+ }
249
+ catch (e) {
250
+ log.debug("sessions", `scan ${dir} failed: ${e}`);
251
+ }
252
+ }
253
+ const sessions = [...byId.values()].filter((s) => s.messageCount > 0);
254
+ sessions.sort((a, b) => Date.parse(b.modifiedTime) - Date.parse(a.modifiedTime));
255
+ return sessions;
256
+ }
257
+ async scanDirectory(claudeDir, byId) {
258
+ let entries;
259
+ try {
260
+ entries = await fs.promises.readdir(claudeDir, { withFileTypes: true });
261
+ }
262
+ catch {
263
+ return;
264
+ }
265
+ for (const entry of entries) {
266
+ if (!entry.isDirectory())
267
+ continue;
268
+ const dirPath = path.join(claudeDir, entry.name);
269
+ let files;
270
+ try {
271
+ files = await fs.promises.readdir(dirPath);
272
+ }
273
+ catch {
274
+ continue;
275
+ }
276
+ for (const fileName of files) {
277
+ if (!fileName.endsWith(".jsonl"))
278
+ continue;
279
+ const sessionId = fileName.slice(0, -".jsonl".length);
280
+ const filePath = path.join(dirPath, fileName);
281
+ let st;
282
+ try {
283
+ st = await fs.promises.stat(filePath);
284
+ }
285
+ catch {
286
+ continue;
287
+ }
288
+ const meta = await claudeMetaCache(filePath, st, () => extractMetadata(filePath));
289
+ if (!meta)
290
+ continue;
291
+ let s = byId.get(sessionId);
292
+ if (!s) {
293
+ s = { id: sessionId, messageCount: 0, modifiedTime: st.mtime.toISOString() };
294
+ byId.set(sessionId, s);
295
+ }
296
+ s.jsonlPath = filePath;
297
+ s.modifiedTime = st.mtime.toISOString();
298
+ s.messageCount = meta.messageCount;
299
+ if (!s.firstPrompt)
300
+ s.firstPrompt = meta.firstPrompt;
301
+ if (!s.projectPath)
302
+ s.projectPath = meta.projectPath ?? decodeDirectoryName(entry.name);
303
+ if (!s.gitBranch)
304
+ s.gitBranch = meta.gitBranch;
305
+ }
306
+ }
307
+ }
308
+ }
309
+ export function sessionDisplayTitle(s) {
310
+ if (s.name)
311
+ return s.name;
312
+ if (s.firstPrompt)
313
+ return takeChars(s.firstPrompt, 100);
314
+ return `${s.id.slice(0, 8)}...`;
315
+ }
@@ -0,0 +1,125 @@
1
+ // Port of settings_store.rs. settings.json stays byte-compatible with the
2
+ // macOS app: same camelCase keys, same defaults.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { devboardDataDir, log, writeFileAtomic } from "./util.js";
6
+ export function defaultSelfCompactRules() {
7
+ return [
8
+ {
9
+ id: "ctx-500k",
10
+ thresholdTokens: 500_000,
11
+ action: "queuePrompt",
12
+ message: "You are above the 500k context limit. Whenever it is convenient, use the devboard CLI to send yourself a self-compact.",
13
+ },
14
+ {
15
+ id: "ctx-600k",
16
+ thresholdTokens: 600_000,
17
+ action: "queuePrompt",
18
+ message: "You are above the 600k context limit. Please compact yourself soon using the devboard CLI self-compact command.",
19
+ },
20
+ {
21
+ id: "ctx-700k",
22
+ thresholdTokens: 700_000,
23
+ action: "queuePrompt",
24
+ message: "You are above the 700k context limit. Compact yourself IMMEDIATELY using the devboard CLI self-compact command.",
25
+ },
26
+ { id: "ctx-750k", thresholdTokens: 750_000, action: "compactNow", message: "/compact" },
27
+ ];
28
+ }
29
+ export function defaultSettings() {
30
+ return {
31
+ projects: [],
32
+ globalView: { excludedPaths: [] },
33
+ github: {
34
+ defaultFilter: "assignee:@me is:open",
35
+ pollIntervalSeconds: 60,
36
+ mergeCommand: "gh pr merge ${number} --squash --delete-branch",
37
+ },
38
+ notifications: {
39
+ notificationsEnabled: true,
40
+ pushoverEnabled: false,
41
+ renderMarkdownImage: false,
42
+ },
43
+ sessionTimeout: { activeThresholdMinutes: 1440 },
44
+ terminalSessions: { idleMinutes: 30, maxSessions: 8 },
45
+ promptTemplate: "",
46
+ githubIssuePromptTemplate: "#${number}: ${title}\n\n${body}",
47
+ hasCompletedOnboarding: false,
48
+ editor: "",
49
+ terminalFontSize: 15,
50
+ sessionDetailFontSize: 12,
51
+ cardDetailMode: "popup",
52
+ selfCompact: { enabled: false, pollIntervalSeconds: 30, rules: defaultSelfCompactRules() },
53
+ apiServices: [],
54
+ defaultAPIServiceIds: {},
55
+ harness: defaultHarnessSettings(),
56
+ };
57
+ }
58
+ export function defaultHarnessSettings() {
59
+ return {
60
+ pullIntervalMinutes: 10,
61
+ injectTopK: 8,
62
+ injectTokenBudget: 600,
63
+ librarian: { enabled: true, model: "haiku", maxTranscriptChars: 60_000 },
64
+ };
65
+ }
66
+ function isPlain(v) {
67
+ return typeof v === "object" && v !== null && !Array.isArray(v);
68
+ }
69
+ /** Fill any missing key with its default (serde `#[serde(default)]` behaviour),
70
+ * recursing into nested objects but treating arrays as opaque. */
71
+ function withDefaults(value, defaults) {
72
+ if (!isPlain(value) || !isPlain(defaults))
73
+ return (value === undefined ? defaults : value);
74
+ const out = { ...value };
75
+ for (const [k, d] of Object.entries(defaults)) {
76
+ out[k] = k in value && value[k] !== undefined ? (isPlain(d) ? withDefaults(value[k], d) : value[k]) : d;
77
+ }
78
+ return out;
79
+ }
80
+ let loggedBackfill = false;
81
+ export class SettingsStore {
82
+ filePath;
83
+ constructor(base) {
84
+ this.filePath = path.join(base ?? devboardDataDir(), "settings.json");
85
+ }
86
+ async read() {
87
+ let raw;
88
+ try {
89
+ raw = await fs.promises.readFile(this.filePath, "utf8");
90
+ }
91
+ catch {
92
+ const defaults = defaultSettings();
93
+ await this.write(defaults);
94
+ return defaults;
95
+ }
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(raw);
99
+ }
100
+ catch {
101
+ return defaultSettings();
102
+ }
103
+ if (!loggedBackfill && isPlain(parsed) && !("terminalFontSize" in parsed)) {
104
+ loggedBackfill = true;
105
+ log.info("settings", "settings.json missing terminalFontSize — backfilling defaults (file appears to originate from macOS)");
106
+ }
107
+ return withDefaults(parsed, defaultSettings());
108
+ }
109
+ async write(settings) {
110
+ await writeFileAtomic(this.filePath, JSON.stringify(settings, null, 2));
111
+ }
112
+ }
113
+ /** Per-card override → per-assistant default → none (mirrors resolve_api_service). */
114
+ export function resolveApiService(settings, cardOverride, assistantId) {
115
+ const services = settings.apiServices ?? [];
116
+ if (cardOverride) {
117
+ const hit = services.find((s) => s.id === cardOverride);
118
+ if (hit)
119
+ return hit;
120
+ }
121
+ const defaultId = settings.defaultAPIServiceIds?.[assistantId];
122
+ if (!defaultId)
123
+ return undefined;
124
+ return services.find((s) => s.id === defaultId);
125
+ }
@@ -0,0 +1,29 @@
1
+ // Links the bundled devboard skill into ~/.claude/skills so agents can find it.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { log, IS_WINDOWS } from "./util.js";
6
+ export function linkDevboardSkill(repoRoot) {
7
+ const link = path.join(os.homedir(), ".claude", "skills", "devboard");
8
+ if (fs.existsSync(path.join(link, "SKILL.md")))
9
+ return;
10
+ const candidates = [process.env.DEVBOARD_REPO, repoRoot, path.resolve(repoRoot, "..")].filter((p) => !!p);
11
+ const src = candidates
12
+ .map((r) => path.join(r, ".claude", "skills", "devboard"))
13
+ .find((p) => fs.existsSync(path.join(p, "SKILL.md")));
14
+ if (!src) {
15
+ log.warn("bootstrap", "devboard skill source not found; skipping link");
16
+ return;
17
+ }
18
+ try {
19
+ fs.mkdirSync(path.dirname(link), { recursive: true });
20
+ if (fs.existsSync(link))
21
+ fs.rmdirSync(link);
22
+ // "junction" needs no admin / Developer Mode on Windows; ignored elsewhere.
23
+ fs.symlinkSync(src, link, IS_WINDOWS ? "junction" : "dir");
24
+ log.info("bootstrap", `linked devboard skill → ${src}`);
25
+ }
26
+ catch (e) {
27
+ log.warn("bootstrap", `skill link failed: ${e}`);
28
+ }
29
+ }
@@ -0,0 +1,217 @@
1
+ // tmux adapter. Ports tmux.rs: on Windows every call goes through
2
+ // `wsl.exe -- tmux …`; elsewhere bare `tmux`.
3
+ //
4
+ // All commands are serialised through the async helpers below — tmux ops are
5
+ // quick, and serialising avoids the paste/Enter ordering races seen on macOS.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { psmuxPath } from "./psmux.js";
9
+ import { IS_WINDOWS, devboardDataDir, log, run } from "./util.js";
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ const bound = (be) => (args, input) => tmux(args, input, be);
12
+ function tmux(args, input, be = "wsl") {
13
+ if (be === "psmux") {
14
+ const exe = psmuxPath();
15
+ if (!exe)
16
+ return Promise.resolve({ ok: false, code: null, stdout: "", stderr: "psmux is not installed" });
17
+ return run(exe, args, { input });
18
+ }
19
+ return IS_WINDOWS ? run("wsl.exe", ["--", "tmux", ...args], { input }) : run("tmux", args, { input });
20
+ }
21
+ const errText = (r) => r.stderr.trim();
22
+ export async function isAvailable(be = "wsl") {
23
+ const tmuxBe = bound(be);
24
+ return (await tmuxBe(["-V"])).ok;
25
+ }
26
+ export async function hasSession(name, be = "wsl") {
27
+ const tmuxBe = bound(be);
28
+ return (await tmuxBe(["has-session", "-t", name])).ok;
29
+ }
30
+ /** `C:\foo` → `/mnt/c/foo` via `wsl wslpath -a`; unchanged for unix-y paths. */
31
+ export async function windowsPathToWsl(p) {
32
+ if (p.startsWith("/") || !IS_WINDOWS)
33
+ return p;
34
+ // wsl.exe runs the command through a shell, which eats backslashes; forward
35
+ // slashes survive and wslpath accepts them.
36
+ const r = await run("wsl.exe", ["--", "wslpath", "-a", p.replaceAll("\\", "/")]);
37
+ const s = r.stdout.trim();
38
+ return r.ok && s ? s : p;
39
+ }
40
+ /** Idempotent: reuses an existing session, else `new-session -A -d`. */
41
+ export async function ensureSession(name, cwdWsl, command, be = "wsl") {
42
+ const tmuxBe = bound(be);
43
+ if (await hasSession(name, be))
44
+ return;
45
+ const args = ["new-session", "-A", "-d", "-s", name, "-c", cwdWsl];
46
+ if (command)
47
+ args.push(command);
48
+ const r = await tmuxBe(args);
49
+ if (!r.ok)
50
+ throw new Error(`tmux new-session failed: ${errText(r)}`);
51
+ log.info("tmux", `created session name=${name} cwd=${cwdWsl}`);
52
+ }
53
+ /**
54
+ * psmux session whose first window runs `inner` (a cmd.exe command line). The line goes through
55
+ * a script file so no quoting survives to be mangled between node, psmux and cmd.
56
+ */
57
+ export async function ensureWindowsSession(name, cwd, inner) {
58
+ if (await hasSession(name, "psmux"))
59
+ return;
60
+ const dir = path.join(devboardDataDir(), "launch");
61
+ await fs.promises.mkdir(dir, { recursive: true });
62
+ const script = path.join(dir, `${name.replace(/[^\w.-]/g, "_")}.cmd`);
63
+ // cmd reads batch files in the OEM code page: switch to UTF-8, or non-ASCII prompts (e.g. Vietnamese)
64
+ // arrive mangled, the session's first prompt stops matching its card and a duplicate card appears.
65
+ // `%` expands in batch files, so double it.
66
+ const body = ["@echo off", "chcp 65001 >nul", inner.replaceAll("%", "%%"), ""].join("\r\n");
67
+ await fs.promises.writeFile(script, body);
68
+ const r = await tmux(["new-session", "-A", "-d", "-s", name, "-c", cwd, "--", "cmd.exe", "/k", script], undefined, "psmux");
69
+ if (!r.ok)
70
+ throw new Error(`psmux new-session failed: ${errText(r)}`);
71
+ log.info("tmux", `created psmux session name=${name} cwd=${cwd}`);
72
+ }
73
+ export async function killSession(name, be = "wsl") {
74
+ const tmuxBe = bound(be);
75
+ if (!(await hasSession(name, be)))
76
+ return;
77
+ const r = await tmuxBe(["kill-session", "-t", name]);
78
+ if (!r.ok)
79
+ throw new Error(`tmux kill-session failed: ${errText(r)}`);
80
+ log.info("tmux", `killed session name=${name}`);
81
+ }
82
+ export async function capturePane(target, be = "wsl") {
83
+ const tmuxBe = bound(be);
84
+ const r = await tmuxBe(["capture-pane", "-p", "-t", target]);
85
+ if (!r.ok)
86
+ throw new Error(errText(r));
87
+ return r.stdout;
88
+ }
89
+ async function pasteBuffer(target, text, be) {
90
+ const tmuxBe = bound(be);
91
+ // Exit copy-mode first (harmless no-op otherwise).
92
+ await tmuxBe(["send-keys", "-X", "-t", target, "cancel"]);
93
+ // Via stdin: robust for arbitrary UTF-8, no shell escaping.
94
+ const load = await tmuxBe(["load-buffer", "-"], text);
95
+ if (!load.ok)
96
+ throw new Error(`tmux load-buffer failed: ${errText(load)}`);
97
+ const paste = await tmuxBe(["paste-buffer", "-p", "-t", target]);
98
+ if (!paste.ok)
99
+ throw new Error(`tmux paste-buffer failed: ${errText(paste)}`);
100
+ }
101
+ /** Bracketed-paste `text` into `target` and submit with Enter. */
102
+ export async function sendPrompt(target, text, be = "wsl") {
103
+ const tmuxBe = bound(be);
104
+ await pasteBuffer(target, text, be);
105
+ // Let Claude finish processing the paste event before Enter, or it treats
106
+ // the Enter as part of the paste and never submits.
107
+ await sleep(100);
108
+ const enter = await tmuxBe(["send-keys", "-t", target, "Enter"]);
109
+ if (!enter.ok)
110
+ throw new Error(`tmux send-keys Enter failed: ${errText(enter)}`);
111
+ await ensurePromptSent(target, be);
112
+ }
113
+ /** Paste without submitting. */
114
+ export function pasteText(target, text, be = "wsl") {
115
+ return pasteBuffer(target, text, be);
116
+ }
117
+ export function needsResubmit(capture) {
118
+ if (capture.includes("[Pasted text") || capture.includes("[Pasted Text"))
119
+ return true;
120
+ const lines = capture.split(/\r?\n/);
121
+ for (let i = lines.length - 1; i >= 0; i--) {
122
+ const t = lines[i].trimEnd();
123
+ if (!t)
124
+ continue;
125
+ const idx = t.indexOf("❯");
126
+ if (idx >= 0)
127
+ return t.slice(idx + 1).trim().length > 0;
128
+ break;
129
+ }
130
+ return false;
131
+ }
132
+ /** Re-fire Enter up to 10× while the pasted text is still in Claude's input box. */
133
+ async function ensurePromptSent(target, be) {
134
+ const tmuxBe = bound(be);
135
+ for (let i = 0; i < 10; i++) {
136
+ await sleep(i === 0 ? 300 : 500);
137
+ let cap;
138
+ try {
139
+ cap = await capturePane(target, be);
140
+ }
141
+ catch {
142
+ return;
143
+ }
144
+ if (!needsResubmit(cap))
145
+ return;
146
+ await tmuxBe(["send-keys", "-t", target, "Enter"]);
147
+ }
148
+ log.warn("tmux", `ensure_prompt_sent gave up after 10 attempts target=${target}`);
149
+ }
150
+ export async function newWindow(session, cwdWsl, command, be = "wsl") {
151
+ const tmuxBe = bound(be);
152
+ const args = ["new-window", "-P", "-F", "#I", "-t", session];
153
+ if (cwdWsl)
154
+ args.push("-c", cwdWsl);
155
+ if (command)
156
+ args.push(command);
157
+ const r = await tmuxBe(args);
158
+ if (!r.ok)
159
+ throw new Error(`tmux new-window failed: ${errText(r)}`);
160
+ const idx = Number.parseInt(r.stdout.trim(), 10);
161
+ if (Number.isNaN(idx))
162
+ throw new Error(`tmux new-window: couldn't parse window index '${r.stdout.trim()}'`);
163
+ return idx;
164
+ }
165
+ export async function killWindow(target, be = "wsl") {
166
+ const tmuxBe = bound(be);
167
+ const r = await tmuxBe(["kill-window", "-t", target]);
168
+ // Caller-side state may already be gone.
169
+ if (!r.ok && !r.stderr.includes("can't find"))
170
+ throw new Error(`tmux kill-window failed: ${errText(r)}`);
171
+ }
172
+ export async function listWindows(session, be = "wsl") {
173
+ const tmuxBe = bound(be);
174
+ if (!(await hasSession(session, be)))
175
+ return [];
176
+ const r = await tmuxBe(["list-windows", "-t", session, "-F", "#I\t#W\t#{window_active}"]);
177
+ if (!r.ok)
178
+ throw new Error(errText(r));
179
+ const out = [];
180
+ for (const line of r.stdout.split(/\r?\n/)) {
181
+ const parts = line.split("\t");
182
+ if (parts.length < 3)
183
+ continue;
184
+ const index = Number.parseInt(parts[0], 10);
185
+ if (Number.isNaN(index))
186
+ continue;
187
+ out.push({ index, name: parts[1], active: parts[2] === "1" });
188
+ }
189
+ return out;
190
+ }
191
+ export async function listSessions(be = "wsl") {
192
+ const tmuxBe = bound(be);
193
+ if (!(await isAvailable(be)))
194
+ return [];
195
+ const r = await tmuxBe([
196
+ "list-sessions",
197
+ "-F",
198
+ "#{session_name}\t#{session_created}\t#{session_attached}\t#{session_windows}",
199
+ ]);
200
+ if (!r.ok)
201
+ return [];
202
+ const out = [];
203
+ for (const line of r.stdout.split(/\r?\n/)) {
204
+ const [name, created, attached, windows] = line.split("\t");
205
+ const createdAt = Number.parseInt(created ?? "", 10);
206
+ if (!name || Number.isNaN(createdAt) || attached === undefined)
207
+ continue;
208
+ out.push({
209
+ runtime: be === "psmux" ? "windows" : "wsl",
210
+ name,
211
+ createdAt,
212
+ attached: attached !== "0",
213
+ windows: Number.parseInt(windows ?? "0", 10) || 0,
214
+ });
215
+ }
216
+ return out;
217
+ }