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/ask.js ADDED
@@ -0,0 +1,252 @@
1
+ // The ask modal's server side: one open question per terminal or per session, from the extension's ask
2
+ // tool. The tool's call waits on it, long-polling /wait until the board answers, the user skips, or the
3
+ // agent is interrupted. Nothing is written to disk: a question outlives neither the board nor its asker.
4
+
5
+ const crypto = require('node:crypto');
6
+ const path = require('node:path');
7
+ const express = require('express');
8
+ const { httpError } = require('./http-error');
9
+
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ const ASK_PATH = '/api/terminals/:terminalId/ask';
12
+ // A session outside the board's terminal asks here; the modal opens over that session's view.
13
+ const SESSION_ASK_PATH = '/api/sessions/:sessionId/ask';
14
+ // The answer of a playground card is its whole state.
15
+ const BODY_LIMIT = '256kb';
16
+ const MAX_LABEL = 200;
17
+ const SIZES = ['md', 'lg', 'full'];
18
+ // $.http.fetch gives up after 30 s, so a wait answers pending before that.
19
+ const WAIT_MAX_MS = 25_000;
20
+ // An answer that lands between two polls waits this long for the next one.
21
+ const DONE_TTL_MS = 60_000;
22
+
23
+ function parseAsk(body) {
24
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
25
+ const { sessionId, title, file, size = 'lg' } = body;
26
+ if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) return 'invalid sessionId';
27
+ if (typeof title !== 'string' || !title.trim() || title.length > MAX_LABEL) {
28
+ return `title must be a non-empty string of at most ${MAX_LABEL} characters`;
29
+ }
30
+ if (typeof file !== 'string' || !path.isAbsolute(file)) return 'file must be an absolute path';
31
+ if (!SIZES.includes(size)) return `size must be one of ${SIZES.join(', ')}`;
32
+ return { sessionId, title, file, size };
33
+ }
34
+
35
+ // A string is a reason the body is refused.
36
+ function parseAnswer(body) {
37
+ const { status, data } = body || {};
38
+ if (status === 'dismissed') return { status };
39
+ if (status !== 'answered') return 'status must be answered or dismissed';
40
+ if (data === undefined) return 'an answer needs data';
41
+ return { status, data };
42
+ }
43
+
44
+ /**
45
+ * @param {object} [o]
46
+ * @param {() => number} [o.now]
47
+ */
48
+ function createAskStore(o = {}) {
49
+ const now = o.now || Date.now;
50
+ const open = new Map();
51
+ const done = new Map();
52
+ const waiters = new Map();
53
+
54
+ function sweep() {
55
+ const t = now();
56
+ for (const [id, d] of done) if (t - d.at > DONE_TTL_MS) done.delete(id);
57
+ }
58
+
59
+ function settle(ask, result) {
60
+ open.delete(ask.owner);
61
+ done.set(ask.id, { result, at: now() });
62
+ for (const w of waiters.get(ask.id) || []) w(result);
63
+ waiters.delete(ask.id);
64
+ }
65
+
66
+ // `owner` is the terminal id or the session id the question belongs to.
67
+ // A new question ends the open one, so the board never holds two.
68
+ function ask(owner, { sessionId, title, file, size }) {
69
+ sweep();
70
+ const old = open.get(owner);
71
+ if (old) settle(old, { status: 'dismissed', reason: 'replaced' });
72
+ const entry = { id: crypto.randomUUID(), owner, sessionId, title, file, size, at: now() };
73
+ open.set(owner, entry);
74
+ return { entry, replaced: old?.id ?? null };
75
+ }
76
+
77
+ function current(owner) {
78
+ return open.get(owner) || null;
79
+ }
80
+
81
+ function end(owner, askId, result) {
82
+ const entry = open.get(owner);
83
+ if (!entry || entry.id !== askId) return false;
84
+ settle(entry, result);
85
+ return true;
86
+ }
87
+
88
+ // Resolves the result once, or pending after `ms`; an unknown id is gone.
89
+ function wait(askId, ms) {
90
+ const d = done.get(askId);
91
+ if (d) {
92
+ done.delete(askId);
93
+ return Promise.resolve(d.result);
94
+ }
95
+ if (![...open.values()].some((a) => a.id === askId)) return Promise.resolve({ status: 'gone' });
96
+ return new Promise((resolve) => {
97
+ const list = waiters.get(askId) || [];
98
+ const finish = (result) => {
99
+ clearTimeout(timer);
100
+ done.delete(askId);
101
+ resolve(result);
102
+ };
103
+ const timer = setTimeout(() => {
104
+ const left = (waiters.get(askId) || []).filter((w) => w !== finish);
105
+ if (left.length) waiters.set(askId, left);
106
+ else waiters.delete(askId);
107
+ resolve({ status: 'pending' });
108
+ }, ms);
109
+ timer.unref?.();
110
+ list.push(finish);
111
+ waiters.set(askId, list);
112
+ });
113
+ }
114
+
115
+ function prune(keep, reason) {
116
+ for (const [owner, entry] of open) if (!keep(owner)) settle(entry, { status: 'dismissed', reason });
117
+ }
118
+
119
+ return { ask, current, end, wait, prune };
120
+ }
121
+
122
+ function askBodyParser() {
123
+ return express.json({ limit: BODY_LIMIT });
124
+ }
125
+
126
+ /**
127
+ * The routes of a question asked from the board's terminal.
128
+ * @param {object} o
129
+ * @param {(token: string|undefined) => boolean} o.authorized the terminal token
130
+ * @param {(terminalId: string) => boolean} o.hasTerminal a live terminal has this id
131
+ */
132
+ function terminalAskRoutes(o) {
133
+ return {
134
+ path: ASK_PATH,
135
+ param: 'terminalId',
136
+ refuse(req, terminalId) {
137
+ if (!o.authorized(req.get('x-terminal-token'))) return [401, 'invalid terminal token'];
138
+ if (!o.hasTerminal(terminalId)) return [404, 'no live terminal with that id'];
139
+ return null;
140
+ },
141
+ };
142
+ }
143
+
144
+ // No token: the asker is a pi process on this machine with no terminal token, and net-guard
145
+ // already refuses a cross-site request.
146
+ const SESSION_ASK_ROUTES = {
147
+ path: SESSION_ASK_PATH,
148
+ param: 'sessionId',
149
+ refuse(req, sessionId) {
150
+ if (!UUID_RE.test(sessionId)) return [400, 'invalid sessionId'];
151
+ if (req.body?.sessionId !== undefined && req.body.sessionId !== sessionId) return [400, 'sessionId does not match the URL'];
152
+ return null;
153
+ },
154
+ };
155
+
156
+ /**
157
+ * @param {import('express').Express} app
158
+ * @param {object} o
159
+ * @param {ReturnType<typeof createAskStore>} o.store
160
+ * @param {string} o.path the route base, with the owner as the `o.param` param
161
+ * @param {string} o.param
162
+ * @param {(req: import('express').Request, owner: string) => [number, string]|null} o.refuse the status and error that refuse a request
163
+ * @param {(absPath: string) => Promise<{content: string|null, kind: string|null}>} o.readPreviewFile
164
+ * @param {(event: object) => void} o.broadcast
165
+ */
166
+ function mountAskRoutes(app, o) {
167
+ const { store, path: base, param } = o;
168
+ const scope = (owner) => ({ [param]: owner });
169
+
170
+ function guard(req, res) {
171
+ const refused = o.refuse(req, req.params[param]);
172
+ return refused && res.status(refused[0]).json({ error: refused[1] });
173
+ }
174
+
175
+ async function readCard(file) {
176
+ let read;
177
+ try {
178
+ read = await o.readPreviewFile(file);
179
+ } catch (e) {
180
+ if (e.status === 404) throw httpError(400, `file not found: ${file}`);
181
+ throw e;
182
+ }
183
+ if (read.kind !== 'html') throw httpError(400, 'file must be an HTML file');
184
+ return read.content;
185
+ }
186
+
187
+ const route = (fn) => (req, res) => fn(req, res).catch((e) => {
188
+ if (res.headersSent) return;
189
+ if (!e.status) console.error(`Error in ${req.method} ${req.path}:`, e);
190
+ res.status(e.status || 500).json({ error: e.status ? e.message : 'Internal error' });
191
+ });
192
+
193
+ const closed = (owner, id, status) => o.broadcast({ type: 'ask:closed', ...scope(owner), id, status });
194
+
195
+ app.post(base, route(async (req, res) => {
196
+ if (guard(req, res)) return;
197
+ const p = parseAsk(req.body);
198
+ if (typeof p === 'string') return res.status(400).json({ error: p });
199
+ await readCard(p.file);
200
+ const owner = req.params[param];
201
+ const { entry, replaced } = store.ask(owner, p);
202
+ if (replaced) closed(owner, replaced, 'dismissed');
203
+ o.broadcast({ type: 'ask:opened', sessionId: entry.sessionId, ...scope(owner), id: entry.id });
204
+ res.json({ id: entry.id });
205
+ }));
206
+
207
+ app.get(base, route(async (req, res) => {
208
+ res.setHeader('Cache-Control', 'no-store');
209
+ const entry = store.current(req.params[param]);
210
+ if (!entry) return res.json({ ask: null });
211
+ const { id, title, size, file, sessionId } = entry;
212
+ let content = null;
213
+ try {
214
+ content = await readCard(file);
215
+ } catch (e) {
216
+ if (!e.status) throw e;
217
+ }
218
+ res.json({ ask: { id, title, size, file, sessionId, content } });
219
+ }));
220
+
221
+ app.get(`${base}/:askId/wait`, route(async (req, res) => {
222
+ if (guard(req, res)) return;
223
+ const ms = Math.min(Math.max(Number(req.query.ms) || WAIT_MAX_MS, 0), WAIT_MAX_MS);
224
+ res.setHeader('Cache-Control', 'no-store');
225
+ res.json(await store.wait(req.params.askId, ms));
226
+ }));
227
+
228
+ // The board answers or skips.
229
+ app.post(`${base}/:askId`, route(async (req, res) => {
230
+ if (guard(req, res)) return;
231
+ const result = parseAnswer(req.body);
232
+ if (typeof result === 'string') return res.status(400).json({ error: result });
233
+ const { [param]: owner, askId } = req.params;
234
+ if (!store.end(owner, askId, result)) return res.status(404).json({ error: 'no such question' });
235
+ closed(owner, askId, result.status);
236
+ res.status(204).end();
237
+ }));
238
+
239
+ // The tool call was interrupted.
240
+ app.delete(`${base}/:askId`, route(async (req, res) => {
241
+ if (guard(req, res)) return;
242
+ const { [param]: owner, askId } = req.params;
243
+ if (!store.end(owner, askId, { status: 'cancelled' })) return res.status(404).json({ error: 'no such question' });
244
+ closed(owner, askId, 'cancelled');
245
+ res.status(204).end();
246
+ }));
247
+ }
248
+
249
+ module.exports = {
250
+ createAskStore, mountAskRoutes, terminalAskRoutes, askBodyParser, parseAsk, parseAnswer,
251
+ ASK_PATH, SESSION_ASK_PATH, SESSION_ASK_ROUTES, WAIT_MAX_MS,
252
+ };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs').promises;
4
+ const path = require('node:path');
5
+
6
+ let seq = 0;
7
+
8
+ // The board reads these files while pi writes them, so it must never see half a file.
9
+ // On Windows a rename over a file another handle has open fails with EPERM or EBUSY until it closes.
10
+ async function writeJsonAtomic(file, data) {
11
+ await fs.mkdir(path.dirname(file), { recursive: true });
12
+ const tmp = `${file}.${process.pid}.${++seq}.tmp`;
13
+ try {
14
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2));
15
+ for (let attempt = 1; ; attempt++) {
16
+ try {
17
+ await fs.rename(tmp, file);
18
+ return;
19
+ } catch (e) {
20
+ if (attempt >= 10 || (e.code !== 'EPERM' && e.code !== 'EBUSY')) throw e;
21
+ await new Promise((r) => setTimeout(r, attempt * 10));
22
+ }
23
+ }
24
+ } catch (e) {
25
+ await fs.rm(tmp, { force: true });
26
+ throw e;
27
+ }
28
+ }
29
+
30
+ module.exports = { writeJsonAtomic };
@@ -0,0 +1,63 @@
1
+ const crypto = require('node:crypto');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+ const { getAgentDir, longPath } = require('./agent-dir');
5
+
6
+ const DEFAULT_PI_AGENT_DIR = longPath(path.join(os.homedir(), '.pi', 'agent'));
7
+
8
+ // --name <value> | --name=<value>; null when absent.
9
+ function getArgValue(name) {
10
+ const argv = process.argv;
11
+ const idx = argv.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
12
+ if (idx === -1) return null;
13
+ const arg = argv[idx];
14
+ if (arg.includes('=')) return arg.split('=').slice(1).join('=');
15
+ return argv[idx + 1] || null;
16
+ }
17
+
18
+ function expandHome(dir) {
19
+ return dir.replace(/^~/, os.homedir());
20
+ }
21
+
22
+ // --dir | PI_DIR/agent | PI_CODING_AGENT_DIR | ~/.pi/agent
23
+ function resolvePiAgentDir() {
24
+ const dir = getArgValue('dir');
25
+ return dir ? longPath(expandHome(dir)) : getAgentDir();
26
+ }
27
+
28
+ function boardDir(dir) {
29
+ return path.join(dir, 'kanban');
30
+ }
31
+
32
+ function isDefaultPiAgentDir(dir) {
33
+ return longPath(dir) === DEFAULT_PI_AGENT_DIR;
34
+ }
35
+
36
+ // localStorage is per browser origin, so every config dir served from the same port would
37
+ // otherwise share last view, collapsed groups and sidebar layout. The default dir keeps the
38
+ // bare keys; any other dir gets a prefix derived from its resolved path.
39
+ function storageNamespace(dir) {
40
+ if (isDefaultPiAgentDir(dir)) return null;
41
+ return `cfg-${crypto.createHash('sha1').update(path.resolve(dir)).digest('hex').slice(0, 8)}`;
42
+ }
43
+
44
+ // Home-relative, forward-slash form for messages and shell commands.
45
+ function displayPath(p) {
46
+ const home = os.homedir();
47
+ const rel = p.startsWith(home) ? `~${p.slice(home.length)}` : p;
48
+ return rel.split(path.sep).join('/');
49
+ }
50
+
51
+ // The harness spells a project path into a temp/log dir name by replacing every
52
+ // non-alphanumeric character with a dash, drive colon and separators included.
53
+ function encodeProjectDirName(p) {
54
+ return p.replace(/[^a-zA-Z0-9]/g, '-');
55
+ }
56
+
57
+ // pi puts no session id in the environment, so the extension sets this for the commands a session runs.
58
+ const SESSION_ID_ENV = 'PI_KANBAN_SESSION_ID';
59
+
60
+ // Both vars come from the board terminal's ptyEnv, so only a session started there has them.
61
+ const boardTerminalId = () => (process.env.PK_URL && process.env.PK_TERMINAL_ID) || null;
62
+
63
+ module.exports = { getArgValue, resolvePiAgentDir, boardDir,isDefaultPiAgentDir, displayPath, storageNamespace, encodeProjectDirName, SESSION_ID_ENV, boardTerminalId };
@@ -0,0 +1,21 @@
1
+ const fs = require('node:fs');
2
+
3
+ const fileCache = new Map();
4
+
5
+ // Parsed Claude Code settings file, or null when it is missing or invalid. Re-read only
6
+ // after its mtime changes.
7
+ function readSettings(file) {
8
+ const mtime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null;
9
+ const hit = fileCache.get(file);
10
+ if (hit && hit.mtime === mtime) return hit.data;
11
+ let data = null;
12
+ if (mtime !== null) {
13
+ try {
14
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
15
+ } catch {}
16
+ }
17
+ fileCache.set(file, { mtime, data });
18
+ return data;
19
+ }
20
+
21
+ module.exports = { readSettings };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ // Runs the optional companion dashboards (pi-cost, pi-inspect) as children of the board, so a pane
4
+ // can show them. The embed contract the tools follow: `node <bin> --port <n> --host 127.0.0.1
5
+ // --no-open` with PI_CODING_AGENT_DIR set, an explicit port binds exactly (taken: exit 1), and
6
+ // GET /api/companion answers {name, version, session}, where session is a path with {id}.
7
+
8
+ const fs = require('node:fs');
9
+ const net = require('node:net');
10
+ const path = require('node:path');
11
+ const readline = require('node:readline');
12
+ const { spawn: nodeSpawn } = require('node:child_process');
13
+ const { writeJsonAtomic } = require('./atomic-write');
14
+ const { isPidAlive: pidAlive } = require('./live-sessions');
15
+ const { readSettings } = require('./claude-settings');
16
+ const { companionStatus } = require('./companions');
17
+
18
+ const START_TIMEOUT_MS = 15000;
19
+ const PROBE_TIMEOUT_MS = 1500;
20
+ const POLL_MS = 100;
21
+
22
+ function binPath(dir) {
23
+ const pkg = readSettings(path.join(dir, 'package.json'));
24
+ const bin = typeof pkg?.bin === 'string' ? pkg.bin : (pkg?.bin?.[pkg.name] ?? Object.values(pkg?.bin || {})[0]);
25
+ return typeof bin === 'string' ? path.join(dir, bin) : null;
26
+ }
27
+
28
+ function freePort() {
29
+ return new Promise((resolve, reject) => {
30
+ const srv = net.createServer().once('error', reject);
31
+ srv.listen(0, '127.0.0.1', () => {
32
+ const { port } = srv.address();
33
+ srv.close(() => resolve(port));
34
+ });
35
+ });
36
+ }
37
+
38
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
39
+
40
+ // The record in <dir>/<id>.json keeps the last port, so a pane URL saved before a board restart
41
+ // still works, and the pid, so a board that ended without stopping its child (kill -9) can adopt it.
42
+ // Only global packages run: one child serves the sessions of every project, so no project's
43
+ // .pi/settings.json picks it.
44
+ function createCompanionHost({ agentDir, dir, spawn = nodeSpawn, isPidAlive = pidAlive, log = console, startTimeoutMs = START_TIMEOUT_MS }) {
45
+ const running = new Map();
46
+ const starting = new Map();
47
+ const ownedPids = new Set();
48
+ const recordFile = (id) => path.join(dir, `${id}.json`);
49
+
50
+ async function probe(c, port) {
51
+ try {
52
+ const res = await fetch(`http://127.0.0.1:${port}/api/companion`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
53
+ const info = res.ok ? await res.json() : null;
54
+ return info?.name === c.pkg ? info : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function pipeLog(c, child) {
61
+ for (const stream of [child.stdout, child.stderr]) {
62
+ if (stream) readline.createInterface({ input: stream }).on('line', (line) => log.log(`[${c.id}] ${line}`));
63
+ }
64
+ }
65
+
66
+ async function ready(c, child, port) {
67
+ const end = Date.now() + startTimeoutMs;
68
+ while (child.exitCode === null && child.signalCode === null && Date.now() < end) {
69
+ const info = await probe(c, port);
70
+ if (info) return info;
71
+ await wait(POLL_MS);
72
+ }
73
+ return null;
74
+ }
75
+
76
+ async function launch(c, preferredPort) {
77
+ const bin = c.dir && binPath(c.dir);
78
+ if (!bin || !fs.existsSync(bin)) return { error: `${c.pkg} is not in the global pi packages`, status: 404 };
79
+ for (const want of [preferredPort, 0]) {
80
+ const port = want || await freePort();
81
+ const child = spawn(process.execPath, [bin, '--port', String(port), '--host', '127.0.0.1', '--no-open'], {
82
+ cwd: c.dir,
83
+ env: { ...process.env, PI_CODING_AGENT_DIR: agentDir },
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ windowsHide: true,
86
+ });
87
+ ownedPids.add(child.pid);
88
+ child.once('exit', (code, signal) => {
89
+ ownedPids.delete(child.pid);
90
+ if (running.get(c.id)?.pid === child.pid) running.delete(c.id);
91
+ log.log(`[${c.id}] exited (${signal || code})`);
92
+ });
93
+ child.once('error', (e) => log.error(`[${c.id}] ${e.message}`));
94
+ pipeLog(c, child);
95
+ const info = await ready(c, child, port);
96
+ if (info) {
97
+ const entry = { pid: child.pid, port, session: info.session };
98
+ running.set(c.id, entry);
99
+ await writeJsonAtomic(recordFile(c.id), { pid: child.pid, port, boardPid: process.pid });
100
+ return entry;
101
+ }
102
+ child.kill();
103
+ }
104
+ return { error: `${c.pkg} did not start`, status: 502 };
105
+ }
106
+
107
+ async function open(c) {
108
+ const rec = readSettings(recordFile(c.id));
109
+ const info = rec?.port ? await probe(c, rec.port) : null;
110
+ if (!info) return launch(c, rec?.port);
111
+ // Another live board on this agent dir shares the child and stops it itself.
112
+ if (rec.boardPid === process.pid || !isPidAlive(rec.boardPid)) {
113
+ ownedPids.add(rec.pid);
114
+ await writeJsonAtomic(recordFile(c.id), { ...rec, boardPid: process.pid });
115
+ }
116
+ const entry = { pid: rec.pid, port: rec.port, session: info.session };
117
+ running.set(c.id, entry);
118
+ return entry;
119
+ }
120
+
121
+ async function start(id) {
122
+ const c = companionStatus({ agentDir }).find((r) => r.id === id && r.optional);
123
+ if (!c) return { error: 'unknown companion', status: 404 };
124
+ const live = running.get(id);
125
+ if (live && isPidAlive(live.pid)) return live;
126
+ if (!starting.has(id)) starting.set(id, open(c).finally(() => starting.delete(id)));
127
+ return starting.get(id);
128
+ }
129
+
130
+ const url = (entry, sessionId) => `http://127.0.0.1:${entry.port}${sessionId ? entry.session.replace('{id}', encodeURIComponent(sessionId)) : '/'}`;
131
+
132
+ // Sync, for process 'exit'. The child is node itself, not a shell, so killing its pid ends the tool.
133
+ function stop() {
134
+ for (const pid of ownedPids) try { process.kill(pid); } catch { /* already gone */ }
135
+ ownedPids.clear();
136
+ running.clear();
137
+ }
138
+
139
+ return { start, url, stop };
140
+ }
141
+
142
+ module.exports = { createCompanionHost };
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ // The pi packages the board's features read from. pi loads a package listed under "packages" in
4
+ // <agentDir>/settings.json (global) or <cwd>/.pi/settings.json (project); see pi's docs/packages.md.
5
+
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { execFile } = require('node:child_process');
9
+ const { readSettings } = require('./claude-settings');
10
+
11
+ const COMPANIONS = [
12
+ { id: 'pi-kanban', pkg: 'pi-kanban', feature: 'Board skills: show, ask, panes, dispatch' },
13
+ { id: 'pi-subagents', pkg: 'pi-subagents', feature: 'Subagents' },
14
+ { id: 'rpiv-todo', pkg: '@juicesharp/rpiv-todo', feature: 'Todos' },
15
+ { id: 'pi-cost', pkg: 'pi-cost', feature: 'Cost pane', optional: true, verb: 'cost', shows: 'cost, tokens and models' },
16
+ { id: 'pi-inspect', pkg: 'pi-inspect', feature: 'Inspect pane', optional: true, verb: 'inspect', shows: 'the system prompt, tools, extensions, skills and context pi loaded' },
17
+ ].map((c) => ({ ...c, source: `npm:${c.pkg}` }));
18
+
19
+ // The companions that open as a session pane, by their verb in `pi-kanban session` and `/kanban`.
20
+ const PANE_COMPANIONS = COMPANIONS.filter((c) => c.verb);
21
+
22
+ const LATEST_TTL_MS = 6 * 60 * 60 * 1000;
23
+ const NPM_VIEW_TIMEOUT_MS = 30000;
24
+ const GIT_SOURCE = /^(git:|https?:\/\/|ssh:\/\/|git@)/;
25
+
26
+ // Mirrors pi's resolvePathFromBase (utils/paths.js normalizePath): trim, expand a leading ~, resolve.
27
+ function localDir(source, baseDir) {
28
+ const s = source.trim();
29
+ const home = process.env.HOME || os.homedir();
30
+ if (s === '~') return home;
31
+ if (s.startsWith('~/') || (process.platform === 'win32' && s.startsWith('~\\'))) return path.join(home, s.slice(2));
32
+ return path.resolve(baseDir, s);
33
+ }
34
+
35
+ // npm and git sources carry the name; a local path names the package in its package.json.
36
+ function packageName(source, baseDir) {
37
+ if (source.startsWith('npm:')) return source.slice(4).replace(/(.)@.*$/, '$1');
38
+ if (GIT_SOURCE.test(source)) {
39
+ return path.posix.basename(source.replace(/@[^/@]*$/, '').replace(/\.git$/, '').replace(/\/+$/, ''));
40
+ }
41
+ const dir = localDir(source, baseDir);
42
+ return readSettings(path.join(dir, 'package.json'))?.name || path.basename(dir);
43
+ }
44
+
45
+ function settingsPackages(file) {
46
+ const list = readSettings(file)?.packages;
47
+ if (!Array.isArray(list)) return [];
48
+ return list
49
+ .map((p) => (typeof p === 'string' ? p : p?.source))
50
+ .filter((s) => typeof s === 'string' && s)
51
+ .map((source) => ({ source, base: path.dirname(file), name: packageName(source, path.dirname(file)) }));
52
+ }
53
+
54
+ // pi reads a local path in place. A git clone has no fixed folder the board can name.
55
+ function packageFolder(at, pkg) {
56
+ if (at.source.startsWith('npm:')) return path.join(at.npmRoot, 'node_modules', pkg);
57
+ return GIT_SOURCE.test(at.source) ? null : localDir(at.source, at.base);
58
+ }
59
+
60
+ const installedVersion = (dir) => readSettings(path.join(dir, 'package.json'))?.version || null;
61
+
62
+ function newerVersion(a, b) {
63
+ const parts = (v) => v.split(/[.+-]/).slice(0, 3).map(Number);
64
+ const [x, y] = [parts(a), parts(b)];
65
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
66
+ return false;
67
+ }
68
+
69
+ const latestVersions = new Map();
70
+
71
+ // pi installs npm packages under <agentDir>/npm (global) or <cwd>/.pi/npm (project), and checks
72
+ // an unpinned one for updates with `npm view <name> version` (DefaultPackageManager.checkForAvailableUpdates).
73
+ function companionStatus({ agentDir, cwd }) {
74
+ const project = typeof cwd === 'string' && path.isAbsolute(cwd);
75
+ const packages = [
76
+ ...settingsPackages(path.join(agentDir, 'settings.json')).map((p) => ({ ...p, scope: 'global', npmRoot: path.join(agentDir, 'npm') })),
77
+ ...(project ? settingsPackages(path.join(cwd, '.pi', 'settings.json')).map((p) => ({ ...p, scope: 'project', npmRoot: path.join(cwd, '.pi', 'npm') })) : []),
78
+ ];
79
+ return COMPANIONS.map((c) => {
80
+ const at = packages.find((p) => p.name === c.pkg);
81
+ if (!at) return { ...c, scope: null, dir: null, version: null, latest: null, update: false };
82
+ const npm = at.source.startsWith('npm:');
83
+ const tracked = npm && !at.source.slice(4).includes('@', 1);
84
+ const dir = packageFolder(at, c.pkg);
85
+ const version = npm ? installedVersion(dir) : null;
86
+ const latest = (tracked && latestVersions.get(c.pkg)?.version) || null;
87
+ return { ...c, scope: at.scope, dir, version, latest, update: !!(version && latest && newerVersion(latest, version)), tracked };
88
+ });
89
+ }
90
+
91
+ // A missing package installs globally; an installed one updates where it is listed, and a project
92
+ // package updates from its project's folder, where pi reads .pi/settings.json.
93
+ function installPlan(id, { agentDir, cwd }) {
94
+ const c = companionStatus({ agentDir, cwd }).find((r) => r.id === id);
95
+ if (!c) return null;
96
+ return { piArgs: [c.scope ? 'update' : 'install', c.source], cwd: c.scope === 'project' ? cwd : null };
97
+ }
98
+
99
+ function offline() {
100
+ const v = process.env.PI_OFFLINE?.toLowerCase();
101
+ return v === '1' || v === 'true' || v === 'yes';
102
+ }
103
+
104
+ function npmView(npmCommand, pkg) {
105
+ const [cmd, ...args] = Array.isArray(npmCommand) && npmCommand[0] ? npmCommand : ['npm'];
106
+ return new Promise((resolve) => {
107
+ // npm is npm.cmd on Windows, and Node runs a .cmd only through a shell. pkg is one of COMPANIONS.
108
+ execFile(cmd, [...args, 'view', pkg, 'version', '--json'], { timeout: NPM_VIEW_TIMEOUT_MS, shell: process.platform === 'win32', windowsHide: true }, (err, stdout) => {
109
+ try {
110
+ const v = err ? null : JSON.parse(stdout);
111
+ resolve(typeof v === 'string' ? v : null);
112
+ } catch {
113
+ resolve(null);
114
+ }
115
+ });
116
+ });
117
+ }
118
+
119
+ // One npm call per package every LATEST_TTL_MS, run off the request. Resolves true when a latest
120
+ // version changed, so the caller can tell the board.
121
+ async function refreshLatest(rows, { agentDir, view = npmView, now = Date.now() }) {
122
+ if (offline()) return false;
123
+ const stale = rows.filter((r) => r.tracked && r.version && !(now - (latestVersions.get(r.pkg)?.at ?? -Infinity) < LATEST_TTL_MS));
124
+ if (!stale.length) return false;
125
+ for (const r of stale) latestVersions.set(r.pkg, { version: latestVersions.get(r.pkg)?.version ?? null, at: now });
126
+ const npmCommand = readSettings(path.join(agentDir, 'settings.json'))?.npmCommand;
127
+ const found = await Promise.all(stale.map(async (r) => {
128
+ const version = await view(npmCommand, r.pkg);
129
+ const before = latestVersions.get(r.pkg).version;
130
+ latestVersions.set(r.pkg, { version, at: now });
131
+ return version !== before;
132
+ }));
133
+ return found.some(Boolean);
134
+ }
135
+
136
+ module.exports = { COMPANIONS, PANE_COMPANIONS, companionStatus, installPlan, refreshLatest, packageName, newerVersion };