pi-kanban 0.3.1 → 2.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 (129) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +61 -21
  3. package/cli.js +1586 -0
  4. package/extensions/kanban.ts +330 -367
  5. package/extensions/package.json +1 -0
  6. package/extensions/tools/ask.ts +85 -0
  7. package/extensions/tools/board-api.ts +142 -0
  8. package/extensions/tools/cli-path.ts +34 -0
  9. package/extensions/tools/dispatch.ts +56 -0
  10. package/extensions/tools/inbox.ts +86 -0
  11. package/extensions/tools/kanban-command.ts +27 -0
  12. package/extensions/tools/scratchpad.ts +70 -0
  13. package/extensions/tsconfig.json +16 -0
  14. package/lib/agent-dir.js +20 -0
  15. package/lib/agent-log.js +64 -0
  16. package/lib/ask.js +252 -0
  17. package/lib/atomic-write.js +30 -0
  18. package/lib/board-dir.js +63 -0
  19. package/lib/claude-settings.js +21 -0
  20. package/lib/companion-host.js +142 -0
  21. package/lib/companions.js +136 -0
  22. package/lib/contain.js +78 -0
  23. package/lib/dispatch-reports.js +86 -0
  24. package/lib/file-url.js +38 -0
  25. package/lib/folder-dialog.js +91 -0
  26. package/lib/frame-policy.js +79 -0
  27. package/lib/git-branch.js +37 -102
  28. package/lib/http-error.js +7 -0
  29. package/lib/inline-assets.js +238 -0
  30. package/lib/linked-docs.js +56 -0
  31. package/lib/live-sessions.js +86 -0
  32. package/lib/model-windows.js +48 -0
  33. package/lib/net-guard.js +277 -0
  34. package/lib/one-line.js +6 -0
  35. package/lib/open-editor.js +173 -0
  36. package/lib/panes.js +213 -0
  37. package/lib/parsers.js +1434 -0
  38. package/lib/pi-agents.js +178 -0
  39. package/lib/pi-git-branch.js +112 -0
  40. package/lib/pi-models.js +34 -0
  41. package/lib/pi-sessions.js +240 -0
  42. package/lib/pi-transcript.js +354 -0
  43. package/lib/preview-action.js +55 -0
  44. package/lib/priority.js +45 -0
  45. package/lib/proc-stats.js +82 -0
  46. package/lib/retention.js +191 -0
  47. package/lib/scratch-files.js +47 -0
  48. package/lib/scratchpad-dir.js +95 -0
  49. package/lib/session-cache.js +32 -0
  50. package/lib/session-events.js +275 -0
  51. package/lib/session-pins.js +99 -0
  52. package/lib/show.js +279 -0
  53. package/lib/stamped-json-file.js +49 -0
  54. package/lib/task-counts.js +90 -0
  55. package/lib/task-dir.js +26 -0
  56. package/lib/task-store.js +44 -47
  57. package/lib/terminal-client.js +248 -0
  58. package/lib/terminal-host.js +113 -0
  59. package/lib/terminal.js +1116 -0
  60. package/lib/user-groups.js +384 -0
  61. package/lib/worktrees.js +132 -0
  62. package/package.json +39 -15
  63. package/public/app.js +14349 -3448
  64. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  65. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  66. package/public/fonts/fonts.css +244 -0
  67. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  68. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  69. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  70. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  71. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  72. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  73. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  74. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  75. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  76. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  77. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  78. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  79. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  80. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  81. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  82. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  83. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  84. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  85. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  86. package/public/group-tree.js +21 -0
  87. package/public/icons/icon-192.png +0 -0
  88. package/public/icons/icon-512.png +0 -0
  89. package/public/icons/icon-svg.svg +7 -5
  90. package/public/index.html +493 -328
  91. package/public/link-url.js +18 -0
  92. package/public/manifest.json +7 -7
  93. package/public/project-match.js +34 -0
  94. package/public/style.css +8301 -2251
  95. package/public/sw.js +4 -3
  96. package/public/terminal-frame.js +437 -0
  97. package/public/terminal.html +26 -0
  98. package/public/themes.css +876 -0
  99. package/public/tree-highlight.js +196 -0
  100. package/server.js +3368 -467
  101. package/skills/ask/SKILL.md +29 -0
  102. package/skills/ask/references/playground.md +25 -0
  103. package/skills/dispatch/SKILL.md +51 -0
  104. package/skills/dispatch/references/orchestration-patterns.md +50 -0
  105. package/skills/kanban/SKILL.md +46 -0
  106. package/skills/show/SKILL.md +41 -0
  107. package/skills/show/references/NOTICE.md +27 -0
  108. package/skills/show/references/chart.md +58 -0
  109. package/skills/show/references/diagram/architecture.md +18 -0
  110. package/skills/show/references/diagram/er.md +10 -0
  111. package/skills/show/references/diagram/flow.md +13 -0
  112. package/skills/show/references/diagram/layers.md +10 -0
  113. package/skills/show/references/diagram/sequence.md +19 -0
  114. package/skills/show/references/diagram/state.md +12 -0
  115. package/skills/show/references/diagram/swimlane.md +10 -0
  116. package/skills/show/references/diagram/timeline.md +9 -0
  117. package/skills/show/references/diagram/tree.md +10 -0
  118. package/skills/show/references/diagram.md +87 -0
  119. package/skills/show/references/explain.md +100 -0
  120. package/skills/show/references/html-contract.md +18 -0
  121. package/skills/show/references/mockup.md +30 -0
  122. package/skills/visualize/SKILL.md +73 -0
  123. package/skills/visualize/blocks.html +68 -0
  124. package/skills/visualize/template.html +179 -0
  125. package/lib/pi-parsers.js +0 -1000
  126. package/themes/kanban-dark-default.json +0 -21
  127. package/themes/kanban-default.json +0 -21
  128. package/themes/pi-dark.json +0 -21
  129. package/themes/pi-light.json +0 -21
package/lib/contain.js ADDED
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ // Path containment check.
4
+ //
5
+ // The obvious implementation — `child.startsWith(root)` — is wrong twice over:
6
+ // it accepts a sibling directory that merely shares a prefix ("/plugins/foo-evil"
7
+ // passes a check for "/plugins/foo"), and it is blind to symlinks, `..`, casing
8
+ // and Windows 8.3 short names. This uses path.relative instead, and canonicalises
9
+ // both sides first.
10
+ //
11
+ // Forked from scripts/security-lib/contain.js in the claude-code-hub repo; this copy is
12
+ // maintained here.
13
+
14
+ const path = require('node:path');
15
+ const fs = require('node:fs');
16
+
17
+ const IS_WIN = process.platform === 'win32';
18
+
19
+ // realpath the deepest existing ancestor, then re-append the non-existent tail,
20
+ // so a not-yet-created file under a symlinked directory still canonicalises.
21
+ function realpathDeepest(target) {
22
+ let cur = target;
23
+ const tail = [];
24
+ for (;;) {
25
+ try {
26
+ const real = fs.realpathSync.native(cur);
27
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
28
+ } catch {
29
+ const parent = path.dirname(cur);
30
+ if (parent === cur) return target; // reached the volume root, nothing resolvable
31
+ tail.push(path.basename(cur));
32
+ cur = parent;
33
+ }
34
+ }
35
+ }
36
+
37
+ function canonical(target, deepest) {
38
+ let out = path.resolve(target);
39
+ if (deepest) out = realpathDeepest(out);
40
+ else { try { out = fs.realpathSync.native(out); } catch { /* may not exist yet */ } }
41
+ // realpathSync.native has already expanded 8.3 short names (PROGRA~1) and fixed
42
+ // the casing of the existing portion; lowercase covers the rest.
43
+ return IS_WIN ? out.toLowerCase() : out;
44
+ }
45
+
46
+ function beneath(canonicalChild, root) {
47
+ const rel = path.relative(canonical(root, false), canonicalChild);
48
+ // '' means child === root. An absolute rel means a different drive or UNC share,
49
+ // which path.relative signals by giving up — reject those.
50
+ if (rel === '') return true;
51
+ if (path.isAbsolute(rel)) return false;
52
+ return rel !== '..' && !rel.startsWith(`..${path.sep}`);
53
+ }
54
+
55
+ /**
56
+ * True iff `child` is `root` itself or lies beneath it.
57
+ * @param {string} child
58
+ * @param {string} root
59
+ */
60
+ function isContained(child, root) {
61
+ if (typeof child !== 'string' || typeof root !== 'string' || !child || !root) return false;
62
+ return beneath(canonical(child, true), root);
63
+ }
64
+
65
+ /**
66
+ * True iff `child` is contained by any of `roots`. Canonicalises the child once
67
+ * instead of once per root — callers testing a single path against several roots
68
+ * were paying that walk repeatedly.
69
+ * @param {string} child
70
+ * @param {string[]} roots
71
+ */
72
+ function isContainedAny(child, roots) {
73
+ if (typeof child !== 'string' || !child || !Array.isArray(roots)) return false;
74
+ const c = canonical(child, true);
75
+ return roots.some((r) => typeof r === 'string' && r && beneath(c, r));
76
+ }
77
+
78
+ module.exports = { isContained, isContainedAny, realpathDeepest };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ // A dispatched session's report back to the session that started it. The worker posts it once it
4
+ // is done; the starter's call long-polls for it. The report lives on the dispatch marker, so it
5
+ // waits there when the starter polls late or the board restarts in between.
6
+
7
+ const MAX_REPORT = 16 * 1024;
8
+ const WAIT_MAX_MS = 25_000;
9
+
10
+ function parseReport(body) {
11
+ const text = body?.text;
12
+ if (typeof text !== 'string' || !text.trim()) return { error: 'text is required' };
13
+ if (text.length > MAX_REPORT) return { error: `text must be at most ${MAX_REPORT} characters` };
14
+ return { text: text.trim() };
15
+ }
16
+
17
+ /**
18
+ * @param {object} o
19
+ * @param {ReturnType<import('./retention').createDispatchedStore>} o.dispatched
20
+ * @param {(session: string) => boolean} o.isRunning the session's terminal still runs
21
+ */
22
+ function createDispatchReports({ dispatched, isRunning }) {
23
+ const waiters = new Map();
24
+
25
+ // Null for a session that was not dispatched.
26
+ function status(id) {
27
+ const marker = dispatched.get(id);
28
+ if (!marker) return null;
29
+ if (marker.report) return { status: 'reported', ...marker.report };
30
+ return isRunning(id) ? { status: 'pending' } : { status: 'ended' };
31
+ }
32
+
33
+ // Wakes the waiters of a session to read its status again: a report came, or its terminal ended.
34
+ function notify(id) {
35
+ const set = waiters.get(id);
36
+ if (!set) return;
37
+ waiters.delete(id);
38
+ for (const wake of set) wake();
39
+ }
40
+
41
+ function post(id, text) {
42
+ const report = dispatched.setReport(id, text);
43
+ if (report) notify(id);
44
+ return report;
45
+ }
46
+
47
+ async function wait(id, ms) {
48
+ const now = status(id);
49
+ if (!now || now.status !== 'pending' || ms <= 0) return now;
50
+ await new Promise((resolve) => {
51
+ const wake = () => {
52
+ clearTimeout(timer);
53
+ resolve();
54
+ };
55
+ const timer = setTimeout(() => {
56
+ const set = waiters.get(id);
57
+ set?.delete(wake);
58
+ if (set && !set.size) waiters.delete(id);
59
+ resolve();
60
+ }, Math.min(ms, WAIT_MAX_MS));
61
+ timer.unref?.();
62
+ if (!waiters.has(id)) waiters.set(id, new Set());
63
+ waiters.get(id).add(wake);
64
+ });
65
+ return status(id);
66
+ }
67
+
68
+ return { status, post, wait, notify };
69
+ }
70
+
71
+ // Shared by the CLI and the pi tools, so both print and match dispatches the same way.
72
+ function dispatchLine(d) {
73
+ return [d.session, d.name, d.group && `[${d.group}]`, d.cwd, d.reportedAt && '(reported)'].filter(Boolean).join(' ');
74
+ }
75
+
76
+ function matchDispatches(rows, query) {
77
+ return rows.filter((r) => r.name === query || r.session.startsWith(query));
78
+ }
79
+
80
+ function reportText(session, r) {
81
+ if (r.status === 'reported') return `Report from ${session}:\n${r.text}`;
82
+ if (r.status === 'ended') return `The terminal of ${session} ended without a report.`;
83
+ return `${session} has not reported yet.`;
84
+ }
85
+
86
+ module.exports = { createDispatchReports, parseReport, dispatchLine, matchDispatches, reportText, MAX_REPORT, WAIT_MAX_MS };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ // Some tools hand out a `file://` URL where a path is expected — Explorer's
4
+ // "copy as path" on a network share, a PDF viewer's location bar, a chat client
5
+ // that linkifies attachments. Windows apps also emit the non-standard backslash
6
+ // spelling `file:\\\C:\dir\a.md`. Both are normalized to a plain OS path so the
7
+ // user does not have to hand-edit what their clipboard produced.
8
+
9
+ const { fileURLToPath } = require('node:url');
10
+
11
+ // Matches any number of leading slashes before a drive letter, so it re-spells both
12
+ // the well-formed `file:///C:/dir` and `file://C:/dir`, where the parser reads C: as
13
+ // a host and rejects the URL outright. Anything else — POSIX paths, UNC shares — is
14
+ // left for the parser.
15
+ const DRIVE_URL = /^file:\/*([a-zA-Z]:.*)$/;
16
+
17
+ /**
18
+ * Convert a `file://` URL to an OS path. Any other string is returned unchanged,
19
+ * as is a URL too malformed to parse — the caller reports "not found" on the
20
+ * literal text, which is more useful than a parser error.
21
+ * @param {string} value
22
+ */
23
+ function fileUrlToPath(value) {
24
+ if (typeof value !== 'string' || !/^file:/i.test(value)) return value;
25
+ // Backslashes are not legal in a URL, so this cannot corrupt a well-formed one.
26
+ let url = value.replace(/\\/g, '/');
27
+ // A scheme with no path body: POSIX Node resolves it to "/" rather than throwing.
28
+ if (/^file:\/*$/i.test(url)) return value;
29
+ const drive = DRIVE_URL.exec(url);
30
+ if (drive) url = `file:///${drive[1]}`;
31
+ try {
32
+ return fileURLToPath(url);
33
+ } catch {
34
+ return value;
35
+ }
36
+ }
37
+
38
+ module.exports = { fileUrlToPath };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ // Opens the OS folder picker on the machine running pi-kanban. The page cannot do this: a
4
+ // browser's directory input hands over file contents, never the folder's path.
5
+
6
+ const { spawn } = require('node:child_process');
7
+ const { statSync } = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ const PROMPT = 'Choose a folder for the new session';
11
+
12
+ // The owner form keeps the dialog above the browser; without it the dialog opens behind.
13
+ // The start folder arrives in an env var, never spliced into the script.
14
+ const WIN_SCRIPT = [
15
+ 'Add-Type -AssemblyName System.Windows.Forms',
16
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
17
+ '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true }',
18
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
19
+ `$d.Description = "${PROMPT}"`,
20
+ '$d.ShowNewFolderButton = $true',
21
+ 'if ($env:PK_PICK_START) { $d.SelectedPath = $env:PK_PICK_START }',
22
+ 'if ($d.ShowDialog($owner) -eq "OK") { [Console]::Out.Write($d.SelectedPath) }',
23
+ ].join('; ');
24
+
25
+ // argv, not string interpolation, so a quote in the path cannot end the AppleScript literal.
26
+ const MAC_SCRIPT = [
27
+ 'on run argv',
28
+ 'if (count of argv) > 0 then',
29
+ `return POSIX path of (choose folder with prompt "${PROMPT}" default location (POSIX file (item 1 of argv)))`,
30
+ 'end if',
31
+ `return POSIX path of (choose folder with prompt "${PROMPT}")`,
32
+ 'end run',
33
+ ];
34
+
35
+ function candidates(platform, start) {
36
+ // No `.exe`: whichSync appends PATHEXT itself, so `powershell.exe` is never found.
37
+ // pwsh first: on .NET Core the same FolderBrowserDialog is the Explorer-style picker, while
38
+ // Windows PowerShell (.NET Framework) shows the old tree view.
39
+ if (platform === 'win32') {
40
+ const args = ['-NoProfile', '-STA', '-Command', WIN_SCRIPT];
41
+ return [['pwsh', args], ['powershell', args]];
42
+ }
43
+ if (platform === 'darwin') return [['osascript', [...MAC_SCRIPT.flatMap((l) => ['-e', l]), ...(start ? [start] : [])]]];
44
+ return [
45
+ ['zenity', ['--file-selection', '--directory', `--title=${PROMPT}`, ...(start ? [`--filename=${start}${path.sep}`] : [])]],
46
+ ['kdialog', ['--getexistingdirectory', start || '.', '--title', PROMPT]],
47
+ ];
48
+ }
49
+
50
+ // The page sends any text the user typed; only an existing absolute folder is worth opening at.
51
+ function startFolder(dir) {
52
+ if (typeof dir !== 'string' || !path.isAbsolute(dir)) return null;
53
+ try {
54
+ return statSync(dir).isDirectory() ? dir : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {(cmd: string) => string|null} which
62
+ * @param {{ start?: string }} [opts] folder the dialog opens at, ignored unless it exists
63
+ * @returns {Promise<string|null>} the chosen folder, or null when the user cancels
64
+ * @throws when this machine has no folder dialog
65
+ */
66
+ function pickFolder(which, opts = {}, platform = process.platform, env = process.env) {
67
+ if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
68
+ return Promise.reject(new Error('no display for a folder dialog'));
69
+ }
70
+ const start = startFolder(opts.start);
71
+ const found = candidates(platform, start).find(([cmd]) => which(cmd));
72
+ if (!found) return Promise.reject(new Error('no folder dialog on this machine (install zenity or kdialog)'));
73
+ const [cmd, args] = found;
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawn(which(cmd), args, {
76
+ stdio: ['ignore', 'pipe', 'ignore'],
77
+ windowsHide: true,
78
+ env: start ? { ...env, PK_PICK_START: start } : env,
79
+ });
80
+ let out = '';
81
+ child.stdout.on('data', (d) => { out += d; });
82
+ child.on('error', reject);
83
+ // Every picker exits non-zero on cancel and prints nothing.
84
+ child.on('close', () => {
85
+ const dir = out.trim();
86
+ resolve(dir ? dir.replace(/(.)[/\\]$/, '$1') : null);
87
+ });
88
+ });
89
+ }
90
+
91
+ module.exports = { pickFolder, candidates, startFolder };
@@ -0,0 +1,79 @@
1
+ // Whether a page lets the board frame it, from its response headers. The parent page gets no
2
+ // signal when a cross-origin frame is refused (Chrome fires `load` on its error page), so the
3
+ // server reads the headers instead. Rules: CSP3 frame-ancestors (it wins over X-Frame-Options
4
+ // when present) and the HTML spec's X-Frame-Options processing.
5
+
6
+ const { portOf } = require('./panes');
7
+
8
+ const PROBE_TIMEOUT_MS = 5000;
9
+
10
+ function parseOrigin(value) {
11
+ try {
12
+ const u = new URL(value);
13
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ // CSP3 lets an http source match https too.
20
+ const schemeOk = (want, got) => want === got || (want === 'http:' && got === 'https:');
21
+
22
+ function matchesSource(source, ancestor, self) {
23
+ const s = source.toLowerCase();
24
+ if (s === "'self'") return ancestor.origin === self.origin;
25
+ if (s === '*') return true;
26
+ if (/^[a-z][a-z0-9+.-]*:$/.test(s)) return schemeOk(s, ancestor.protocol);
27
+ const m = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[^:/]+)(?::(\d+|\*))?(?:\/.*)?$/.exec(s);
28
+ if (!m) return false;
29
+ const [, scheme, host, port] = m;
30
+ const want = scheme ? `${scheme}:` : self.protocol;
31
+ if (!schemeOk(want, ancestor.protocol)) return false;
32
+ const hostOk = host === '*' || (host.startsWith('*.') ? ancestor.hostname.endsWith(host.slice(1)) : host === ancestor.hostname);
33
+ if (!hostOk) return false;
34
+ if (port === '*' || (!port && ancestor.port === '')) return true;
35
+ return Number(port) === portOf(ancestor) || (port === '80' && ancestor.protocol === 'https:' && ancestor.port === '');
36
+ }
37
+
38
+ function frameAncestorsLists(csp) {
39
+ const lists = [];
40
+ for (const policy of String(csp || '').split(',')) {
41
+ for (const directive of policy.split(';')) {
42
+ const [name, ...sources] = directive.trim().split(/\s+/);
43
+ if (name?.toLowerCase() === 'frame-ancestors') {
44
+ lists.push(sources);
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ return lists;
50
+ }
51
+
52
+ // headers: Fetch Headers. target: the final URL of the response. ancestors: every origin above
53
+ // the frame. Answers true, false, or null when it cannot tell.
54
+ function framingVerdict(headers, { target, ancestors }) {
55
+ const self = parseOrigin(target);
56
+ const chain = ancestors.map(parseOrigin).filter(Boolean);
57
+ if (!self || !chain.length) return null;
58
+ const lists = frameAncestorsLists(headers.get('content-security-policy'));
59
+ if (lists.length) return lists.every((sources) => chain.every((a) => sources.some((src) => matchesSource(src, a, self))));
60
+ const xfo = String(headers.get('x-frame-options') || '')
61
+ .split(',')
62
+ .map((v) => v.trim().toLowerCase());
63
+ if (xfo.includes('deny')) return false;
64
+ return !xfo.includes('sameorigin') || chain.every((a) => a.origin === self.origin);
65
+ }
66
+
67
+ // GETs the page and drops the body once the headers arrive. A network error or a timeout
68
+ // answers null, and the board frames the page anyway.
69
+ async function probeFraming(target, ancestors, { fetchImpl = fetch } = {}) {
70
+ try {
71
+ const res = await fetchImpl(target, { redirect: 'follow', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
72
+ res.body?.cancel().catch(() => {});
73
+ return framingVerdict(res.headers, { target: res.url || target, ancestors });
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ module.exports = { framingVerdict, parseOrigin, probeFraming };
package/lib/git-branch.js CHANGED
@@ -1,112 +1,47 @@
1
+ // Read from HEAD rather than by spawning git: a sync spawn on the session-list path blocks the
2
+ // event loop for up to half a second per cwd on Windows, and terminal input waits behind it.
3
+
1
4
  const fs = require('node:fs');
2
5
  const path = require('node:path');
3
- const { spawn } = require('node:child_process');
4
-
5
- // cwd -> { branch: string|null, headMtimeMs: number, isRepo: boolean, checkedAt: number }
6
- const cache = new Map();
7
- const inflight = new Map();
8
-
9
- const MAX_CONCURRENT = 4;
10
- const SPAWN_TIMEOUT_MS = 500;
11
- // re-check non-repos every 30s so newly-cloned repos surface without a server restart
12
- const NEGATIVE_TTL_MS = 30_000;
13
- // skip statSync within this window of a known-good cache hit; keeps list endpoints from
14
- // hitting the FS once per session per request when the same cwds repeat
15
- const POSITIVE_TTL_MS = 2_000;
16
- let running = 0;
17
- const queue = [];
18
-
19
- function pump() {
20
- while (running < MAX_CONCURRENT && queue.length) {
21
- const job = queue.shift();
22
- running++;
23
- job().finally(() => { running--; pump(); });
24
- }
25
- }
26
-
27
- function headMtime(cwd) {
28
- try { return fs.statSync(path.join(cwd, '.git', 'HEAD')).mtimeMs; }
29
- catch { return null; }
30
- }
31
-
32
- function spawnGit(cwd) {
33
- return new Promise((resolve) => {
34
- let stdout = '';
35
- let done = false;
36
- let proc;
37
- try {
38
- proc = spawn('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
39
- windowsHide: true,
40
- });
41
- } catch { resolve(null); return; }
42
- const timer = setTimeout(() => {
43
- if (done) return;
44
- done = true;
45
- try { proc.kill(); } catch {}
46
- resolve(null);
47
- }, SPAWN_TIMEOUT_MS);
48
- proc.stdout.on('data', (b) => { stdout += b.toString(); });
49
- proc.on('error', () => {
50
- if (done) return;
51
- done = true; clearTimeout(timer); resolve(null);
52
- });
53
- proc.on('close', (code) => {
54
- if (done) return;
55
- done = true; clearTimeout(timer);
56
- if (code !== 0) { resolve(null); return; }
57
- const branch = stdout.trim();
58
- resolve(branch || null);
59
- });
60
- });
61
- }
62
6
 
63
- function getBranch(cwd, onResolved) {
64
- if (!cwd) return null;
65
- const entry = cache.get(cwd);
66
- const now = Date.now();
67
-
68
- if (entry && entry.isRepo && now - entry.checkedAt < POSITIVE_TTL_MS) {
69
- return entry.branch;
70
- }
71
-
72
- const mtime = headMtime(cwd);
73
-
74
- if (mtime === null) {
75
- if (entry && entry.isRepo === false && now - entry.checkedAt < NEGATIVE_TTL_MS) {
76
- return null;
77
- }
78
- cache.set(cwd, { branch: null, headMtimeMs: 0, isRepo: false, checkedAt: now });
7
+ const REF_RE = /^ref:\s*refs\/heads\/(.+)$/;
8
+ const GITDIR_RE = /^gitdir:\s*(.+)$/;
9
+
10
+ // `.git` is a directory in an ordinary checkout and a `gitdir:` pointer file in a linked
11
+ // worktree or a submodule.
12
+ function headFile(dir, readFile) {
13
+ const dotGit = path.join(dir, '.git');
14
+ let text;
15
+ try {
16
+ text = readFile(dotGit);
17
+ } catch (e) {
18
+ if (e.code === 'EISDIR') return path.join(dotGit, 'HEAD');
79
19
  return null;
80
20
  }
81
-
82
- if (entry && entry.isRepo && entry.headMtimeMs === mtime) {
83
- entry.checkedAt = now;
84
- return entry.branch;
85
- }
86
-
87
- scheduleResolve(cwd, mtime, entry, onResolved);
88
- return entry && entry.isRepo ? entry.branch : null;
21
+ const m = GITDIR_RE.exec(text.trim());
22
+ return m ? path.join(path.resolve(dir, m[1]), 'HEAD') : null;
89
23
  }
90
24
 
91
- function scheduleResolve(cwd, mtime, prevEntry, onResolved) {
92
- if (inflight.has(cwd)) {
93
- if (onResolved) inflight.get(cwd).then((b) => {
94
- if (!prevEntry || prevEntry.branch !== b) onResolved(b);
95
- });
96
- return;
25
+ /**
26
+ * @param {string} cwd
27
+ * @param {(file: string) => string} [readFile]
28
+ * @returns {string|null} null outside a repo and on a detached HEAD
29
+ */
30
+ function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
31
+ for (let dir = path.resolve(cwd); ; ) {
32
+ const head = headFile(dir, readFile);
33
+ if (head) {
34
+ try {
35
+ const m = REF_RE.exec(readFile(head).trim());
36
+ return m ? m[1] : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+ const up = path.dirname(dir);
42
+ if (up === dir) return null;
43
+ dir = up;
97
44
  }
98
- const prevBranch = prevEntry ? prevEntry.branch : undefined;
99
- const p = new Promise((resolve) => {
100
- queue.push(async () => {
101
- const branch = await spawnGit(cwd);
102
- cache.set(cwd, { branch, headMtimeMs: mtime, isRepo: branch !== null, checkedAt: Date.now() });
103
- inflight.delete(cwd);
104
- resolve(branch);
105
- });
106
- pump();
107
- });
108
- inflight.set(cwd, p);
109
- if (onResolved) p.then((b) => { if (b !== prevBranch) onResolved(b); });
110
45
  }
111
46
 
112
- module.exports = { getBranch };
47
+ module.exports = { readGitBranch };
@@ -0,0 +1,7 @@
1
+ // An error a route answers with `status`; `code` is the machine-readable reason, where one is set.
2
+ // `expose` lets the API error handler send the message for a 5xx too, because it was written for the user.
3
+ function httpError(status, message, code) {
4
+ return Object.assign(new Error(message), { status, code, expose: true });
5
+ }
6
+
7
+ module.exports = { httpError };