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
@@ -0,0 +1,238 @@
1
+ // Inlines an HTML file's local assets so it renders standalone inside the preview's
2
+ // `srcdoc` iframe. An `about:srcdoc` document has no URL of its own, so every relative
3
+ // href resolves against the app origin and 404s — the only way a sibling stylesheet,
4
+ // script or image can reach the frame is embedded in the document text.
5
+ //
6
+ // Remote refs (https:, //cdn, data:) are left untouched: they already resolve.
7
+
8
+ const path = require('node:path');
9
+ const fs = require('node:fs').promises;
10
+
11
+ // Individually large assets are the ones that blow up as base64 (+33%), and the total
12
+ // guards the modal: the whole document is handed to the client in one JSON response.
13
+ // Both count bytes on disk, charged once per unique file — the delivered document is
14
+ // larger than the total whenever an asset is base64'd or referenced twice.
15
+ const MAX_ASSET_BYTES = 4 * 1024 * 1024;
16
+ const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
17
+ // @import chains are followed, so a cycle needs both a depth stop and a seen-set.
18
+ const MAX_IMPORT_DEPTH = 5;
19
+
20
+ const MIME_BY_EXT = {
21
+ '.png': 'image/png',
22
+ '.jpg': 'image/jpeg',
23
+ '.jpeg': 'image/jpeg',
24
+ '.gif': 'image/gif',
25
+ '.webp': 'image/webp',
26
+ '.avif': 'image/avif',
27
+ '.svg': 'image/svg+xml',
28
+ '.ico': 'image/x-icon',
29
+ '.bmp': 'image/bmp',
30
+ '.woff': 'font/woff',
31
+ '.woff2': 'font/woff2',
32
+ '.ttf': 'font/ttf',
33
+ '.otf': 'font/otf',
34
+ '.eot': 'application/vnd.ms-fontobject',
35
+ '.mp4': 'video/mp4',
36
+ '.webm': 'video/webm',
37
+ '.ogv': 'video/ogg',
38
+ '.mp3': 'audio/mpeg',
39
+ '.wav': 'audio/wav',
40
+ '.ogg': 'audio/ogg',
41
+ '.json': 'application/json',
42
+ '.css': 'text/css',
43
+ '.js': 'text/javascript'
44
+ };
45
+
46
+ function isRemoteRef(ref) {
47
+ return !ref || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith('//') || ref.startsWith('#');
48
+ }
49
+
50
+ function mimeFor(file) {
51
+ return MIME_BY_EXT[path.extname(file).toLowerCase()] || 'application/octet-stream';
52
+ }
53
+
54
+ // Reads an attribute out of a tag, quoted or bare. Returns undefined when absent.
55
+ function attrOf(tag, name) {
56
+ const q = new RegExp(`${name.replace(':', '\\:')}\\s*=\\s*(['"])(.*?)\\1`, 'i');
57
+ return tag.match(q)?.[2] ?? tag.match(new RegExp(`${name.replace(':', '\\:')}\\s*=\\s*([^\\s>]+)`, 'i'))?.[1];
58
+ }
59
+
60
+ // Rewrites every match of `re` in one pass: `fn` receives the match array and returns
61
+ // the replacement, or null to leave that match alone. Replacements are resolved
62
+ // concurrently (each reads its own asset) and spliced by offset, so the string is
63
+ // rebuilt once instead of once per match.
64
+ async function replaceMatches(text, re, fn) {
65
+ const matches = [...text.matchAll(re)];
66
+ if (!matches.length) return text;
67
+ const replacements = await Promise.all(matches.map(m => fn(m)));
68
+ const out = [];
69
+ let cursor = 0;
70
+ matches.forEach((m, i) => {
71
+ if (replacements[i] == null) return;
72
+ out.push(text.slice(cursor, m.index), replacements[i]);
73
+ cursor = m.index + m[0].length;
74
+ });
75
+ out.push(text.slice(cursor));
76
+ return out.join('');
77
+ }
78
+
79
+ // One context threads through a whole document: the cache means an asset referenced by
80
+ // twenty rules is read, size-checked and charged exactly once, and nested @import/url()
81
+ // chains can't each spend the full allowance.
82
+ async function loadAsset(absPath, ctx) {
83
+ if (!ctx.cache.has(absPath)) {
84
+ ctx.cache.set(
85
+ absPath,
86
+ (async () => {
87
+ const stat = await fs.stat(absPath);
88
+ if (!stat.isFile()) throw new Error('not a file');
89
+ if (stat.size > MAX_ASSET_BYTES || ctx.spent + stat.size > MAX_TOTAL_BYTES) {
90
+ ctx.skipped.push({ path: absPath, size: stat.size, reason: 'too large' });
91
+ return null;
92
+ }
93
+ ctx.spent += stat.size;
94
+ return fs.readFile(absPath);
95
+ })()
96
+ );
97
+ }
98
+ return ctx.cache.get(absPath);
99
+ }
100
+
101
+ async function loadText(absPath, ctx) {
102
+ return (await loadAsset(absPath, ctx))?.toString('utf8') ?? null;
103
+ }
104
+
105
+ async function toDataUri(absPath, ctx) {
106
+ const buf = await loadAsset(absPath, ctx);
107
+ return buf === null ? null : `data:${mimeFor(absPath)};base64,${buf.toString('base64')}`;
108
+ }
109
+
110
+ // Resolves a ref against `baseDir` and hands the loaded asset to `use`. Remote refs and
111
+ // unreadable files yield null, which every caller treats as "leave the markup as
112
+ // authored" so the frame degrades to the pre-inlining behaviour.
113
+ async function withAsset(ref, baseDir, use) {
114
+ if (isRemoteRef(ref)) return null;
115
+ try {
116
+ return await use(path.resolve(baseDir, ref));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?(['"]?)([^'")\s;]+)\1\s*\)?\s*([^;]*);/gi;
123
+ const CSS_URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
124
+
125
+ // Rewrites url(...) targets and follows @import into the imported sheet's own directory —
126
+ // a nested sheet's relative refs are relative to *it*, not to the document.
127
+ async function inlineCss(css, baseDir, ctx, depth = 0, seen = new Set()) {
128
+ let out = css;
129
+
130
+ if (depth < MAX_IMPORT_DEPTH) {
131
+ out = await replaceMatches(out, CSS_IMPORT, ([, , ref, media]) =>
132
+ withAsset(ref, baseDir, async abs => {
133
+ if (seen.has(abs)) return null;
134
+ const text = await loadText(abs, ctx);
135
+ if (text === null) return null;
136
+ const nested = await inlineCss(text, path.dirname(abs), ctx, depth + 1, new Set([...seen, abs]));
137
+ // A media query on the @import has to survive as a wrapper or the rules leak.
138
+ return media.trim() ? `@media ${media.trim()}{\n${nested}\n}` : nested;
139
+ })
140
+ );
141
+ }
142
+
143
+ return replaceMatches(out, CSS_URL, ([, , ref]) =>
144
+ withAsset(ref, baseDir, async abs => {
145
+ const uri = await toDataUri(abs, ctx);
146
+ return uri && `url("${uri}")`;
147
+ })
148
+ );
149
+ }
150
+
151
+ // `</script>` anywhere in inlined JS (a string, a regex, a template) would close the tag
152
+ // early and dump the rest as markup.
153
+ function escapeScript(js) {
154
+ return js.replace(/<\/script/gi, '<\\/script');
155
+ }
156
+
157
+ function inlineStylesheets(html, baseDir, ctx) {
158
+ return replaceMatches(html, /<link\b[^>]*>/gi, async ([tag]) => {
159
+ if (!/rel\s*=\s*(['"]?)[^'">]*\bstylesheet\b/i.test(tag)) return null;
160
+ return withAsset(attrOf(tag, 'href'), baseDir, async abs => {
161
+ const css = await loadText(abs, ctx);
162
+ if (css === null) return null;
163
+ const inlined = await inlineCss(css, path.dirname(abs), ctx, 0, new Set([abs]));
164
+ const media = attrOf(tag, 'media');
165
+ return `<style${media ? ` media="${media}"` : ''}>\n${inlined}\n</style>`;
166
+ });
167
+ });
168
+ }
169
+
170
+ function inlineStyleElements(html, baseDir, ctx) {
171
+ return replaceMatches(html, /<style\b[^>]*>([\s\S]*?)<\/style>/gi, async ([tag, css]) => {
172
+ if (!/url\(|@import/i.test(css)) return null;
173
+ const inlined = await inlineCss(css, baseDir, ctx);
174
+ return inlined === css ? null : tag.replace(css, inlined);
175
+ });
176
+ }
177
+
178
+ function inlineScripts(html, baseDir, ctx) {
179
+ return replaceMatches(html, /<script\b[^>]*\bsrc\s*=[^>]*>\s*<\/script>/gi, ([tag]) =>
180
+ withAsset(attrOf(tag, 'src'), baseDir, async abs => {
181
+ const js = await loadText(abs, ctx);
182
+ if (js === null) return null;
183
+ // Keep type= (module vs classic changes semantics); drop src/defer/async, which
184
+ // mean nothing once the body is inline.
185
+ const type = attrOf(tag, 'type');
186
+ return `<script${type ? ` type="${type}"` : ''}>\n${escapeScript(js)}\n</script>`;
187
+ })
188
+ );
189
+ }
190
+
191
+ // src/poster/href on media and image tags. `<use href>` covers sprite sheets, which are
192
+ // the one SVG case that silently renders nothing when unresolved.
193
+ const SRC_ATTR_TAGS = /<(img|source|video|audio|use)\b[^>]*>/gi;
194
+ const SRC_ATTR = /\b(src|poster|href|xlink:href)\s*=\s*(['"])(.*?)\2/gi;
195
+
196
+ function inlineElementSrcs(html, baseDir, ctx) {
197
+ return replaceMatches(html, SRC_ATTR_TAGS, async ([tag]) => {
198
+ let out = await replaceMatches(tag, SRC_ATTR, ([, name, , ref]) =>
199
+ withAsset(ref, baseDir, async abs => {
200
+ const uri = await toDataUri(abs, ctx);
201
+ return uri && `${name}="${uri}"`;
202
+ })
203
+ );
204
+ // srcset is a comma-separated candidate list, each entry `url [descriptor]`.
205
+ const srcset = out.match(/srcset\s*=\s*(['"])(.*?)\1/i);
206
+ if (srcset) {
207
+ const parts = await Promise.all(
208
+ srcset[2].split(',').map(async entry => {
209
+ const [ref, ...rest] = entry.trim().split(/\s+/);
210
+ const uri = await withAsset(ref, baseDir, abs => toDataUri(abs, ctx));
211
+ return uri ? [uri, ...rest].join(' ') : entry.trim();
212
+ })
213
+ );
214
+ out = out.replace(srcset[0], `srcset="${parts.join(', ')}"`);
215
+ }
216
+ return out === tag ? null : out;
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Embeds every resolvable local asset an HTML document references.
222
+ *
223
+ * @returns {Promise<{ html: string, bytes: number, skipped: Array<{path:string,size:number,reason:string}> }>}
224
+ */
225
+ async function inlineHtmlAssets(html, absHtmlPath) {
226
+ const baseDir = path.dirname(absHtmlPath);
227
+ const ctx = { spent: 0, skipped: [], cache: new Map() };
228
+ // Author-written <style> blocks first: after inlineStylesheets a <link> has become a
229
+ // <style> whose refs are already resolved, and re-walking megabytes of data URIs to
230
+ // find nothing is the one pass worth ordering around.
231
+ let out = await inlineStyleElements(html, baseDir, ctx);
232
+ out = await inlineStylesheets(out, baseDir, ctx);
233
+ out = await inlineScripts(out, baseDir, ctx);
234
+ out = await inlineElementSrcs(out, baseDir, ctx);
235
+ return { html: out, bytes: ctx.spent, skipped: ctx.skipped };
236
+ }
237
+
238
+ module.exports = { inlineHtmlAssets, MIME_BY_EXT, MAX_ASSET_BYTES };
@@ -0,0 +1,56 @@
1
+ // Server copy of each session's linked docs: file paths and http(s) URLs. The browser keeps its own list in localStorage
2
+ // and merges this one in when it connects, so a `doc link` sent while no tab is open is not lost.
3
+ // Newest first, capped like the browser list.
4
+
5
+ const { linkUrl, MAX_LINKED_DOCS } = require('../public/link-url');
6
+
7
+ // Same rule as `canonicalPath` in public/app.js: one Windows file is reached as `C:\a` and `c:/a`.
8
+ function canonicalPath(p) {
9
+ const slashed = String(p).replace(/\\/g, '/');
10
+ return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed;
11
+ }
12
+
13
+ /**
14
+ * @param {object} o
15
+ * @param {() => object|null} o.load returns `{sessions: {[id]: string[]}}` or null
16
+ * @param {(data: object) => void} o.save
17
+ */
18
+ function createLinkedDocStore({ load, save }) {
19
+ const bySession = new Map();
20
+ const saved = load()?.sessions;
21
+ if (saved && typeof saved === 'object') {
22
+ for (const [id, paths] of Object.entries(saved)) {
23
+ const clean = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p) : [];
24
+ if (clean.length) bySession.set(id, clean.slice(0, MAX_LINKED_DOCS));
25
+ }
26
+ }
27
+
28
+ const persist = () => save({ version: 1, sessions: Object.fromEntries(bySession) });
29
+
30
+ function link(sessionId, filePath) {
31
+ const key = canonicalPath(filePath);
32
+ const rest = (bySession.get(sessionId) || []).filter((p) => canonicalPath(p) !== key);
33
+ bySession.set(sessionId, [filePath, ...rest].slice(0, MAX_LINKED_DOCS));
34
+ persist();
35
+ }
36
+
37
+ // With no path, forgets every doc of the session. Returns whether anything was removed.
38
+ function unlink(sessionId, filePath) {
39
+ const paths = bySession.get(sessionId);
40
+ if (!paths) return false;
41
+ const key = filePath == null ? null : canonicalPath(filePath);
42
+ const rest = key === null ? [] : paths.filter((p) => canonicalPath(p) !== key);
43
+ if (rest.length === paths.length) return false;
44
+ if (rest.length) bySession.set(sessionId, rest);
45
+ else bySession.delete(sessionId);
46
+ persist();
47
+ return true;
48
+ }
49
+
50
+ const get = (sessionId) => [...(bySession.get(sessionId) || [])];
51
+ const all = () => Object.fromEntries([...bySession].map(([id, paths]) => [id, [...paths]]));
52
+
53
+ return { link, unlink, get, all };
54
+ }
55
+
56
+ module.exports = { createLinkedDocStore, canonicalPath, linkUrl, MAX_LINKED_DOCS };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { writeJsonAtomic } = require('./atomic-write');
6
+
7
+ // The live-session registry the pi extension writes: one <pid>.json per interactive pi process.
8
+ function readLiveSessions(sessionsDir) {
9
+ const sessions = [];
10
+ let files;
11
+ try {
12
+ files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
13
+ } catch {
14
+ return sessions;
15
+ }
16
+ for (const file of files) {
17
+ try {
18
+ const s = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
19
+ if (s?.sessionId && s.kind === 'interactive') {
20
+ sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, sessionDir: s.sessionDir || null, startedAt: s.startedAt || 0, status: s.status || null, statusUpdatedAt: s.statusUpdatedAt || null, name: s.name || null });
21
+ }
22
+ } catch { /* skip invalid */ }
23
+ }
24
+ return sessions;
25
+ }
26
+
27
+ // EPERM means the process exists but belongs to someone else.
28
+ function isPidAlive(pid) {
29
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
30
+ }
31
+
32
+ // A registry file outlives a crashed pi, so the pid is probed rather than trusted.
33
+ function isSessionLive(sessions, sessionId, exceptPid = null) {
34
+ return sessions.some((s) => {
35
+ if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
36
+ return isPidAlive(s.pid);
37
+ });
38
+ }
39
+
40
+ // /new and /resume keep pi's process and registry file and swap its sessionId, so the id
41
+ // that pid held before is finished even though its transcript was written moments ago. `replaced`
42
+ // maps that id to when the swap was seen; an id that shows up live again leaves it.
43
+ function noteReplacedSessions(prev, next, replaced, now, ttlMs) {
44
+ for (const [id, at] of replaced) if (now - at > ttlMs) replaced.delete(id);
45
+ if (prev) {
46
+ const byPid = new Map(next.filter((s) => s.pid).map((s) => [s.pid, s.sessionId]));
47
+ for (const s of prev) {
48
+ const id = s.pid && byPid.get(s.pid);
49
+ if (id && id !== s.sessionId) replaced.set(s.sessionId, now);
50
+ }
51
+ }
52
+ for (const s of next) replaced.delete(s.sessionId);
53
+ }
54
+
55
+ const liveFile = (dir, pid) => path.join(dir, `${pid}.json`);
56
+
57
+ // The pi extension writes the registry that readLiveSessions reads: one file per pi process,
58
+ // rewritten in place when /new, /resume or /fork swaps the session that process runs.
59
+ async function writeLiveSession(dir, entry) {
60
+ await writeJsonAtomic(liveFile(dir, entry.pid), { kind: 'interactive', ...entry });
61
+ }
62
+
63
+ async function removeLiveSession(dir, pid) {
64
+ await fs.promises.rm(liveFile(dir, pid), { force: true });
65
+ }
66
+
67
+ // For a process 'exit' handler, where async work never runs.
68
+ function removeLiveSessionSync(dir, pid) {
69
+ fs.rmSync(liveFile(dir, pid), { force: true });
70
+ }
71
+
72
+ // A pi that was killed never removes its file.
73
+ async function sweepDeadLiveSessions(dir) {
74
+ let files;
75
+ try {
76
+ files = await fs.promises.readdir(dir);
77
+ } catch {
78
+ return;
79
+ }
80
+ for (const file of files) {
81
+ const pid = /^(\d+)\.json$/.exec(file)?.[1];
82
+ if (pid && !isPidAlive(Number(pid))) await fs.promises.rm(path.join(dir, file), { force: true });
83
+ }
84
+ }
85
+
86
+ module.exports = { readLiveSessions, isPidAlive, isSessionLive, noteReplacedSessions, writeLiveSession, removeLiveSession, removeLiveSessionSync, sweepDeadLiveSessions };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { writeJsonAtomic } = require('./atomic-write');
5
+
6
+ // A session file names the model of each reply but not its context window, and `pi --list-models`
7
+ // rounds the window. The pi extension copies the exact windows of the models pi has auth for from
8
+ // its model registry into this file, keyed "<provider>/<model id>".
9
+ const MODEL_WINDOWS_FILE = 'model-windows.json';
10
+
11
+ const modelKey = (provider, id) => `${provider}/${id}`;
12
+
13
+ function readModelWindows(file) {
14
+ try {
15
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
16
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ // Each pi process adds its registry and keeps the entries it does not know, so an old session
23
+ // whose model another pi registered still finds its window.
24
+ async function recordModelWindows(file, models) {
25
+ const windows = readModelWindows(file);
26
+ let changed = false;
27
+ for (const m of models) {
28
+ if (!m?.provider || !m.id || !(m.contextWindow > 0)) continue;
29
+ const key = modelKey(m.provider, m.id);
30
+ if (windows[key] === m.contextWindow) continue;
31
+ windows[key] = m.contextWindow;
32
+ changed = true;
33
+ }
34
+ if (changed) await writeJsonAtomic(file, windows);
35
+ return changed;
36
+ }
37
+
38
+ // The context of the last reply as pi counts it (calculateContextTokens in its compaction module).
39
+ // Null when the reply has no usage or its model's window is unknown.
40
+ function contextOf(reply, windows) {
41
+ const u = reply?.usage;
42
+ const window = reply?.provider && reply.model ? windows[modelKey(reply.provider, reply.model)] : null;
43
+ if (!u || !(window > 0)) return null;
44
+ const tokens = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens;
45
+ return tokens > 0 ? { tokens, window } : null;
46
+ }
47
+
48
+ module.exports = { MODEL_WINDOWS_FILE, readModelWindows, recordModelWindows, contextOf };