@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Pure parser for Keep-a-Changelog-style markdown files.
3
+ *
4
+ * Pi (`@mariozechner/pi-coding-agent`) ships a `CHANGELOG.md` whose
5
+ * format is mechanically reliable:
6
+ * - H2 release headers: `## [<version>] - <date>`
7
+ * - H3 sub-section headers: `### Breaking Changes`, `### New Features`,
8
+ * `### Added`, `### Changed`, `### Fixed`
9
+ * - Bullets at column 0 starting `- `
10
+ * - Issue/PR links as `([#NNN](URL))` at end of bullet
11
+ *
12
+ * The parser is regex-based on purpose — a full markdown AST would
13
+ * add hundreds of LOC of dependency surface for marginal gain. When
14
+ * the file deviates from convention the parser degrades gracefully:
15
+ * unrecognized H3 headings are dropped from typed slots but the
16
+ * release's `raw` field still contains the verbatim section.
17
+ *
18
+ * Plus a 60-second mtime-keyed in-memory cache so the REST route
19
+ * doesn't re-parse 150 KB of markdown on every dialog open.
20
+ *
21
+ * See change: pi-update-whats-new-panel.
22
+ */
23
+ import fs from "node:fs";
24
+ import type { ChangelogBullet, ChangelogRelease } from "@blackbelt-technology/pi-dashboard-shared/changelog-types.js";
25
+
26
+ /** Single line, anchors at line start: `## [<version>] - <date>` (date optional). */
27
+ const RELEASE_HEADER_RE = /^## \[([^\]]+)\](?:\s*-\s*(.+?))?\s*$/gm;
28
+
29
+ /** Single line, anchors at line start: `### <heading>`. */
30
+ const SUBSECTION_HEADER_RE = /^### (.+?)\s*$/gm;
31
+
32
+ /** End-of-bullet issue ref: `([#NNN](URL))`. */
33
+ const ISSUE_LINK_RE = /\(\[#(\d+)\]\((https?:\/\/[^)]+)\)\)/g;
34
+
35
+ /** Subset of YYYY-MM-DD-ish date strings we treat as "valid enough". */
36
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
37
+
38
+ /** Recognized H3 sub-section names → release-field key. */
39
+ const KNOWN_SUBSECTIONS: Record<string, "breaking" | "features" | "changed" | "fixed"> = {
40
+ "Breaking Changes": "breaking",
41
+ "New Features": "features",
42
+ "Added": "features",
43
+ "Changed": "changed",
44
+ "Fixed": "fixed",
45
+ };
46
+
47
+ /**
48
+ * Parse a CHANGELOG.md text into a list of release entries, latest
49
+ * first. Pure function — no I/O. Returns `[]` when the input contains
50
+ * no recognizable H2 release headers.
51
+ */
52
+ export function parseChangelog(markdown: string): ChangelogRelease[] {
53
+ if (!markdown || typeof markdown !== "string") return [];
54
+
55
+ // Locate every H2 release header and split the document by them.
56
+ // The regex's `lastIndex` walks the string giving us each header's
57
+ // (start offset, captured groups) in one pass.
58
+ const headers: { version: string; date: string | null; start: number; bodyStart: number }[] = [];
59
+
60
+ // Reset the global regex lastIndex to be safe across re-entrant calls.
61
+ RELEASE_HEADER_RE.lastIndex = 0;
62
+ let m: RegExpExecArray | null;
63
+ while ((m = RELEASE_HEADER_RE.exec(markdown)) !== null) {
64
+ const version = m[1].trim();
65
+ const dateRaw = (m[2] ?? "").trim();
66
+ const date = ISO_DATE_RE.test(dateRaw) ? dateRaw : null;
67
+ headers.push({
68
+ version,
69
+ date,
70
+ start: m.index,
71
+ bodyStart: m.index + m[0].length,
72
+ });
73
+ }
74
+
75
+ if (headers.length === 0) return [];
76
+
77
+ // Sort by occurrence order (already in order since regex walks
78
+ // left→right). Assemble each release as the slice from this
79
+ // header's start to (the next header's start, or EOF).
80
+ const releases: ChangelogRelease[] = [];
81
+ for (let i = 0; i < headers.length; i++) {
82
+ const h = headers[i];
83
+ const nextStart = i + 1 < headers.length ? headers[i + 1].start : markdown.length;
84
+ const raw = markdown.slice(h.start, nextStart).replace(/\s+$/, "");
85
+ const body = markdown.slice(h.bodyStart, nextStart);
86
+ const sections = splitSubsections(body);
87
+
88
+ const release: ChangelogRelease = {
89
+ version: h.version,
90
+ date: h.date,
91
+ breaking: [],
92
+ features: [],
93
+ changed: [],
94
+ fixed: [],
95
+ raw,
96
+ };
97
+
98
+ for (const sec of sections) {
99
+ const slot = KNOWN_SUBSECTIONS[sec.heading];
100
+ if (!slot) continue;
101
+ const bullets = extractBullets(sec.body);
102
+ // `features` is the merge of New Features + Added; both map to
103
+ // the same slot, so just push in source order.
104
+ release[slot].push(...bullets);
105
+ }
106
+ releases.push(release);
107
+ }
108
+
109
+ return releases;
110
+ }
111
+
112
+ /**
113
+ * Walk a release's body (text between its H2 and the next H2) and
114
+ * return one entry per H3 sub-heading. Text before the first H3 is
115
+ * dropped — pi never puts content there.
116
+ */
117
+ function splitSubsections(body: string): { heading: string; body: string }[] {
118
+ const out: { heading: string; body: string }[] = [];
119
+ const positions: { heading: string; bodyStart: number; headerStart: number }[] = [];
120
+
121
+ SUBSECTION_HEADER_RE.lastIndex = 0;
122
+ let m: RegExpExecArray | null;
123
+ while ((m = SUBSECTION_HEADER_RE.exec(body)) !== null) {
124
+ positions.push({
125
+ heading: m[1].trim(),
126
+ headerStart: m.index,
127
+ bodyStart: m.index + m[0].length,
128
+ });
129
+ }
130
+ for (let i = 0; i < positions.length; i++) {
131
+ const p = positions[i];
132
+ const nextStart = i + 1 < positions.length ? positions[i + 1].headerStart : body.length;
133
+ out.push({ heading: p.heading, body: body.slice(p.bodyStart, nextStart) });
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * Extract bullets from a sub-section body. A bullet starts with `- `
140
+ * at column 0 and continues until the next column-0 `- ` line, the
141
+ * next H-heading line, or end-of-section.
142
+ */
143
+ function extractBullets(body: string): ChangelogBullet[] {
144
+ const lines = body.split("\n");
145
+ const bullets: string[] = [];
146
+ let current: string | null = null;
147
+
148
+ for (const line of lines) {
149
+ if (/^- /.test(line)) {
150
+ if (current !== null) bullets.push(current);
151
+ current = line.slice(2);
152
+ } else if (current !== null) {
153
+ // Continuation of the current bullet only when the line is
154
+ // either blank-and-followed-by-content (we drop blanks) or
155
+ // indented. Stop on an undented non-bullet line that looks
156
+ // like new content.
157
+ if (line.trim() === "") {
158
+ // Skip blank lines inside a bullet block; they may separate
159
+ // paragraphs but don't terminate the bullet.
160
+ continue;
161
+ }
162
+ if (/^\s/.test(line)) {
163
+ current += "\n" + line.replace(/^\s+/, "");
164
+ } else {
165
+ // Undented non-bullet line: treat as end of bullet block.
166
+ bullets.push(current);
167
+ current = null;
168
+ }
169
+ }
170
+ }
171
+ if (current !== null) bullets.push(current);
172
+
173
+ return bullets.map((text) => ({
174
+ text: text.trim(),
175
+ issues: extractIssues(text),
176
+ }));
177
+ }
178
+
179
+ /** Collect all `([#NNN](URL))` matches in a bullet's prose. */
180
+ function extractIssues(text: string): { num: number; url: string }[] {
181
+ const out: { num: number; url: string }[] = [];
182
+ ISSUE_LINK_RE.lastIndex = 0;
183
+ let m: RegExpExecArray | null;
184
+ while ((m = ISSUE_LINK_RE.exec(text)) !== null) {
185
+ const num = parseInt(m[1], 10);
186
+ if (!Number.isNaN(num)) out.push({ num, url: m[2] });
187
+ }
188
+ return out;
189
+ }
190
+
191
+ // ── Cache ──────────────────────────────────────────────────────────
192
+
193
+ const CACHE_TTL_MS = 60_000;
194
+
195
+ export type ChangelogSource = "local" | "remote";
196
+
197
+ interface CacheEntry {
198
+ /** Result of parsing the file/text. */
199
+ releases: ChangelogRelease[];
200
+ /**
201
+ * mtime of the source file when this entry was created (local source
202
+ * only). 0 for remote entries (mtime irrelevant; ETag drives remote
203
+ * freshness).
204
+ */
205
+ mtimeMs: number;
206
+ /** Wall-clock expiry. */
207
+ expiresAt: number;
208
+ /** ETag from a remote response, if any. Used for conditional GET. */
209
+ etag: string | null;
210
+ }
211
+
212
+ /** Cache key combines pkg + source so remote and local don't collide. */
213
+ function cacheKey(pkg: string, source: ChangelogSource): string {
214
+ return `${source}:${pkg}`;
215
+ }
216
+
217
+ const cache = new Map<string, CacheEntry>();
218
+
219
+ /**
220
+ * Read + parse a CHANGELOG.md file with a 60-second mtime-keyed
221
+ * cache. The cache is keyed by `pkg`; the entry is invalidated when
222
+ * the file's mtime changes (e.g. after a fresh `npm install`) or
223
+ * when 60 seconds have elapsed.
224
+ *
225
+ * Returns `[]` (not throwing) for ENOENT — callers treat "not found"
226
+ * the same as "no releases". Other I/O errors propagate.
227
+ */
228
+ export function readAndParseChangelog(
229
+ pkg: string,
230
+ filePath: string,
231
+ now: () => number = Date.now,
232
+ ): ChangelogRelease[] {
233
+ let stat: fs.Stats;
234
+ try {
235
+ stat = fs.statSync(filePath);
236
+ } catch (err: any) {
237
+ if (err?.code === "ENOENT") return [];
238
+ throw err;
239
+ }
240
+ const mtimeMs = stat.mtimeMs;
241
+ const t = now();
242
+
243
+ const key = cacheKey(pkg, "local");
244
+ const hit = cache.get(key);
245
+ if (hit && hit.mtimeMs === mtimeMs && t < hit.expiresAt) {
246
+ return hit.releases;
247
+ }
248
+
249
+ const content = fs.readFileSync(filePath, "utf8");
250
+ const releases = parseChangelog(content);
251
+ cache.set(key, {
252
+ releases,
253
+ mtimeMs,
254
+ expiresAt: t + CACHE_TTL_MS,
255
+ etag: null,
256
+ });
257
+ return releases;
258
+ }
259
+
260
+ /**
261
+ * Cache-hit accessor for the remote-source entry. Returns the cached
262
+ * releases + last-known ETag. Caller decides what to do with `expired`
263
+ * (typically: try a conditional GET, reuse on 304).
264
+ */
265
+ export function getCachedRemoteChangelog(
266
+ pkg: string,
267
+ now: () => number = Date.now,
268
+ ): { releases: ChangelogRelease[]; etag: string | null; expired: boolean } | undefined {
269
+ const hit = cache.get(cacheKey(pkg, "remote"));
270
+ if (!hit) return undefined;
271
+ return {
272
+ releases: hit.releases,
273
+ etag: hit.etag,
274
+ expired: now() >= hit.expiresAt,
275
+ };
276
+ }
277
+
278
+ /** Store a remote fetch result. */
279
+ export function setRemoteChangelog(
280
+ pkg: string,
281
+ releases: ChangelogRelease[],
282
+ etag: string | null,
283
+ now: () => number = Date.now,
284
+ ): void {
285
+ cache.set(cacheKey(pkg, "remote"), {
286
+ releases,
287
+ mtimeMs: 0,
288
+ expiresAt: now() + CACHE_TTL_MS,
289
+ etag,
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Extend a remote cache entry's TTL without re-parsing. Used after a
295
+ * 304 Not Modified response.
296
+ */
297
+ export function refreshRemoteChangelogTtl(
298
+ pkg: string,
299
+ now: () => number = Date.now,
300
+ ): void {
301
+ const entry = cache.get(cacheKey(pkg, "remote"));
302
+ if (entry) entry.expiresAt = now() + CACHE_TTL_MS;
303
+ }
304
+
305
+ /** Clear all cache entries. Test seam + invalidation hook. */
306
+ export function _resetChangelogCache(): void {
307
+ cache.clear();
308
+ }
309
+
310
+ /**
311
+ * Clear a single package's cache entries (both local and remote
312
+ * sources). Wired into PiCoreChecker.invalidate.
313
+ */
314
+ export function invalidateChangelogCache(pkg?: string): void {
315
+ if (pkg === undefined) {
316
+ cache.clear();
317
+ return;
318
+ }
319
+ cache.delete(cacheKey(pkg, "local"));
320
+ cache.delete(cacheKey(pkg, "remote"));
321
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Fetches the upstream CHANGELOG.md from GitHub raw, so the dashboard
3
+ * can show release notes for versions newer than the locally-installed
4
+ * tarball describes.
5
+ *
6
+ * Trust model identical to `pi-dev-version-check`: HTTPS, default Node
7
+ * trust store, 10-second timeout, env-skippable via `PI_OFFLINE`.
8
+ *
9
+ * See change: read-changelog-from-github.
10
+ */
11
+
12
+ const DEFAULT_TIMEOUT_MS = 10_000;
13
+
14
+ /** Discriminated result of a remote fetch attempt. */
15
+ export type RemoteChangelogResult =
16
+ | { status: "ok"; text: string; etag: string | null }
17
+ | { status: "not-modified" }
18
+ | null; // hard failure (network, parse, env-skipped) → caller falls back to local
19
+
20
+ export interface FetchRemoteChangelogOptions {
21
+ /** Optional ETag from a previous response. When set, sent as `If-None-Match`. */
22
+ etag?: string | null;
23
+ /** Override fetch timeout. Default 10 s. */
24
+ timeoutMs?: number;
25
+ /** Test seam. */
26
+ fetchImpl?: typeof fetch;
27
+ }
28
+
29
+ /**
30
+ * Sibling of `deriveChangelogUrl` that returns the *raw* form suitable
31
+ * for parsing (vs the `/blob/main/` URL which is for human viewing).
32
+ *
33
+ * Accepts the same `repository` field shapes:
34
+ * - `"github:org/repo"` shorthand
35
+ * - URL strings (`"https://github.com/org/repo.git"`, `"git@github.com:org/repo.git"`, etc.)
36
+ * - object form `{ url, directory? }` (monorepos)
37
+ *
38
+ * Returns `null` for non-GitHub or unparseable input.
39
+ */
40
+ export function deriveChangelogRawUrl(repository: unknown): string | null {
41
+ if (!repository) return null;
42
+
43
+ let urlStr: string | null = null;
44
+ let directory: string | null = null;
45
+
46
+ if (typeof repository === "string") {
47
+ urlStr = repository;
48
+ } else if (typeof repository === "object" && repository !== null) {
49
+ const rec = repository as Record<string, unknown>;
50
+ if (typeof rec.url === "string") urlStr = rec.url;
51
+ if (typeof rec.directory === "string" && rec.directory.length > 0) {
52
+ directory = rec.directory.replace(/^\/+|\/+$/g, "");
53
+ }
54
+ }
55
+ if (!urlStr) return null;
56
+
57
+ const m = parseGitHubUrl(urlStr);
58
+ if (!m) return null;
59
+
60
+ const subPath = directory ? `${directory}/` : "";
61
+ return `https://raw.githubusercontent.com/${m.org}/${m.repo}/main/${subPath}CHANGELOG.md`;
62
+ }
63
+
64
+ /**
65
+ * Parse the various GitHub URL forms used in `package.json#repository`
66
+ * into `{ org, repo }`. Internal helper duplicating the same logic in
67
+ * `changelog-fs.ts::parseGitHubUrl` — kept inline to avoid cross-module
68
+ * coupling for a 10-line regex.
69
+ */
70
+ function parseGitHubUrl(s: string): { org: string; repo: string } | null {
71
+ const trimmed = s.trim();
72
+
73
+ // github:org/repo shorthand
74
+ let m = trimmed.match(/^github:([^/]+)\/([^/#]+)/i);
75
+ if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
76
+
77
+ // git+https://github.com/org/repo.git
78
+ // https://github.com/org/repo
79
+ // ssh://git@github.com/org/repo.git
80
+ // git@github.com:org/repo.git
81
+ m = trimmed.match(/(?:^|[/@:])github\.com[/:]([^/]+)\/([^/#?]+)/i);
82
+ if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
83
+
84
+ return null;
85
+ }
86
+
87
+ function stripGitSuffix(repo: string): string {
88
+ return repo.replace(/\.git$/i, "");
89
+ }
90
+
91
+ /**
92
+ * Fetch the markdown text at `rawUrl`. Returns:
93
+ * - `{ status: "ok", text, etag }` on 2xx
94
+ * - `{ status: "not-modified" }` on 304 (when If-None-Match was sent and
95
+ * server confirmed the cached body is current)
96
+ * - `null` for: PI_OFFLINE / env-skipped, non-2xx (other than 304),
97
+ * network error, abort, malformed response.
98
+ *
99
+ * Caller is responsible for falling back to the local CHANGELOG on
100
+ * `null` return.
101
+ */
102
+ export async function fetchRemoteChangelog(
103
+ rawUrl: string,
104
+ opts: FetchRemoteChangelogOptions = {},
105
+ ): Promise<RemoteChangelogResult> {
106
+ if (process.env.PI_OFFLINE) return null;
107
+
108
+ const fetchFn = opts.fetchImpl ?? fetch;
109
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
110
+ const headers: Record<string, string> = { accept: "text/plain, */*" };
111
+ if (opts.etag) headers["If-None-Match"] = opts.etag;
112
+
113
+ try {
114
+ const response = await fetchFn(rawUrl, {
115
+ headers,
116
+ signal: AbortSignal.timeout(timeoutMs),
117
+ redirect: "follow",
118
+ });
119
+
120
+ // 304: server confirms cache is current. Caller reuses cached body.
121
+ if (response.status === 304) {
122
+ return { status: "not-modified" };
123
+ }
124
+ if (!response.ok) return null;
125
+
126
+ const text = await response.text();
127
+ if (typeof text !== "string" || text.length === 0) return null;
128
+
129
+ const etag = response.headers.get("etag");
130
+ return { status: "ok", text, etag };
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node --import tsx
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * PI Dashboard Server CLI
4
4
  *
@@ -17,11 +17,13 @@
17
17
  */
18
18
  import { createServer, type ServerConfig } from "./server.js";
19
19
  import { loadConfig, ensureConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
20
- import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
21
- import { spawnNodeScript } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
22
- import { createRequire } from "node:module";
23
- import { fileURLToPath, pathToFileURL } from "node:url";
24
- import fs from "node:fs";
20
+ import {
21
+ launchDashboardServer,
22
+ JitiNotFoundError,
23
+ PortConflictError,
24
+ EarlyExitError,
25
+ } from "@blackbelt-technology/pi-dashboard-shared/server-launcher.js";
26
+ import { fileURLToPath } from "node:url";
25
27
  import os from "node:os";
26
28
  import path from "node:path";
27
29
  import { readPid, removePid, isServerRunning } from "./server-pid.js";
@@ -42,7 +44,7 @@ export function findPortHolders(
42
44
  }
43
45
  import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
44
46
  import { discoverDashboard } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js";
45
- import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
47
+
46
48
  import { assertNodeVersionSupported } from "./node-guard.js";
47
49
  import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
48
50
  import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
@@ -141,6 +143,7 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
141
143
  shutdownIdleSeconds: fileConfig.shutdownIdleSeconds,
142
144
  tunnel: flags.tunnel ?? fileConfig.tunnel.enabled,
143
145
  tunnelReservedToken: fileConfig.tunnel.reservedToken,
146
+ tunnelWatchdog: fileConfig.tunnel.watchdog,
144
147
  authConfig: fileConfig.auth,
145
148
  maxEventsPerSession: fileConfig.memoryLimits.maxEventsPerSession,
146
149
  maxStringFieldSize: fileConfig.memoryLimits.maxStringFieldSize,
@@ -252,7 +255,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
252
255
  return;
253
256
  }
254
257
 
255
- const installPackages = ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"];
258
+ const installPackages = ["@earendil-works/pi-coding-agent", "@fission-ai/openspec"];
256
259
  server.bootstrapState.setLastInstallPackages(installPackages);
257
260
  console.log("[bootstrap] installing (pi unresolved, running background install)");
258
261
  server.bootstrapState.set({
@@ -351,7 +354,10 @@ async function cmdStart(config: ServerConfig): Promise<void> {
351
354
  process.exit(1);
352
355
  }
353
356
 
354
- // Spawn ourselves in foreground mode (no subcommand) as a detached process
357
+ // Spawn ourselves in foreground mode (no subcommand) as a detached process.
358
+ // All concerns below — jiti loader resolution, --import argv URL-wrapping,
359
+ // env merge, log-file header, readiness polling, port-conflict / early-exit
360
+ // detection — are owned by the shared `launchDashboardServer` primitive.
355
361
  const cliPath = fileURLToPath(import.meta.url);
356
362
  const args: string[] = [];
357
363
  if (config.port !== 8000) args.push("--port", String(config.port));
@@ -359,83 +365,39 @@ async function cmdStart(config: ServerConfig): Promise<void> {
359
365
  if (config.dev) args.push("--dev");
360
366
  if (!config.tunnel) args.push("--no-tunnel");
361
367
 
362
- let tsLoader: string;
363
- try {
364
- tsLoader = resolveJitiImport();
365
- } catch {
366
- // Fallback to tsx when jiti is not available (e.g. running outside pi).
367
- // The loader is passed to `node --import`; on Windows, Node >= 20 rejects
368
- // raw absolute paths with a drive letter (parsed as URL scheme), so we
369
- // return a file:// URL. See change: fix-windows-server-parity.
370
- try {
371
- const tsxMain = createRequire(cliPath).resolve("tsx");
372
- const tsxLoaderPath = path.join(path.dirname(tsxMain), "esm", "index.mjs");
373
- tsLoader = pathToFileURL(tsxLoaderPath).href;
374
- } catch {
375
- console.error(
376
- "[pi-dashboard] Cannot find TypeScript loader. " +
377
- "Install tsx (`npm install`) or run inside a pi session."
378
- );
379
- process.exit(1);
380
- }
381
- }
382
-
383
- // Redirect daemon stdout/stderr to a log file for crash diagnosis.
384
- // Log is opened in append mode ("a") so output from prior start attempts
385
- // is preserved across retries — critical for diagnosing intermittent or
386
- // silent launch failures. A timestamped header line distinguishes runs.
387
- // See change: fix-windows-server-parity.
388
368
  const logDir = path.join(os.homedir(), ".pi", "dashboard");
389
- fs.mkdirSync(logDir, { recursive: true });
390
369
  const logPath = path.join(logDir, "server.log");
391
- const logFd = fs.openSync(logPath, "a");
392
- fs.writeSync(
393
- logFd,
394
- `\n[${new Date().toISOString()}] pi-dashboard start (parent pid ${process.pid}, port ${config.port})\n`,
395
- );
396
-
397
- // Both tsLoader and cliPath are wrapped as file:// URLs by spawnNodeScript.
398
- // Required on Windows for node --import (see change: fix-windows-entry-script-url).
399
- const child = spawnNodeScript({
400
- loader: tsLoader,
401
- entry: cliPath,
402
- args,
403
- spawnOptions: {
404
- detached: true,
405
- stdio: ["ignore", logFd, logFd],
370
+
371
+ try {
372
+ const result = await launchDashboardServer({
373
+ cliPath,
374
+ extraArgs: args,
375
+ stdio: { logFile: logPath },
376
+ starter: "Standalone",
377
+ healthTimeoutMs: 30_000,
378
+ port: config.port,
406
379
  env: { ...process.env },
407
- },
408
- });
409
- child.unref();
410
- // Close the parent's copy of the fd — child has its own via stdio inheritance.
411
- try { fs.closeSync(logFd); } catch { /* ignore */ }
412
-
413
- // Wait for dashboard to become available. Windows + jiti cold-start can
414
- // take 10s+ (TS compile on first boot, native module loads). 30s is the
415
- // outer bound — if the server isn't up by then, something's genuinely wrong.
416
- const READINESS_TIMEOUT_MS = 30_000;
417
- const deadline = Date.now() + READINESS_TIMEOUT_MS;
418
- let started = false;
419
- while (Date.now() < deadline) {
420
- // Also bail if the child has already exited (fast-path crash detection).
421
- if (child.exitCode !== null) break;
422
- await new Promise((r) => setTimeout(r, 300));
423
- const status = await isDashboardRunning(config.port);
424
- if (status.running) {
425
- started = true;
426
- break;
380
+ });
381
+ const reportedPid = result.reportedPid ?? readPid() ?? result.childPid;
382
+ console.log(`Dashboard server started (pid ${reportedPid}) at http://localhost:${config.port}`);
383
+ } catch (err: unknown) {
384
+ if (err instanceof JitiNotFoundError) {
385
+ console.error(`[pi-dashboard] ${err.message}`);
386
+ process.exit(1);
427
387
  }
428
- }
429
-
430
- if (started) {
431
- const pid = readPid();
432
- console.log(`Dashboard server started (pid ${pid ?? child.pid}) at http://localhost:${config.port}`);
433
- } else {
434
- const reason = child.exitCode !== null
435
- ? `child process exited with code ${child.exitCode}`
436
- : `timed out after ${READINESS_TIMEOUT_MS / 1000}s`;
388
+ if (err instanceof PortConflictError) {
389
+ console.error(`Port ${err.port} is occupied by another service (not the dashboard).`);
390
+ console.error(`Change the port in ~/.pi/dashboard/config.json or use --port <n>`);
391
+ process.exit(1);
392
+ }
393
+ if (err instanceof EarlyExitError) {
394
+ console.error(`Failed to start dashboard server (child process exited with code ${err.code})`);
395
+ console.error(`Check logs at ${logPath}`);
396
+ process.exit(1);
397
+ }
398
+ const reason = err instanceof Error ? err.message : String(err);
437
399
  console.error(`Failed to start dashboard server (${reason})`);
438
- console.error(`Check logs at ${path.join(logDir, "server.log")}`);
400
+ console.error(`Check logs at ${logPath}`);
439
401
  process.exit(1);
440
402
  }
441
403
  }
@@ -598,7 +560,7 @@ async function cmdUpgradePi(config: ServerConfig): Promise<void> {
598
560
 
599
561
  console.log("[upgrade-pi] no dashboard running — installing directly");
600
562
  const res = await bootstrapInstall({
601
- packages: ["@mariozechner/pi-coding-agent"],
563
+ packages: ["@earendil-works/pi-coding-agent"],
602
564
  progress: (p) => {
603
565
  const line = p.output
604
566
  ? `[upgrade-pi] ${p.step} ${p.status}: ${p.output}`
@@ -666,7 +628,25 @@ async function cmdStatus(port: number): Promise<void> {
666
628
  console.log(`Dashboard server is running (pid ${pid}) on port ${port}`);
667
629
  }
668
630
 
631
+ /**
632
+ * Install process-level safety net so a single misbehaving plugin or
633
+ * library cannot kill the whole dashboard. Logs the offending error and
634
+ * keeps the event loop running. We do NOT exit; the surrounding daemon
635
+ * harness already restarts on real crashes (signal/exit-code), and
636
+ * silently swallowing recoverable async faults is the lesser evil here.
637
+ */
638
+ function installCrashSafetyNet(): void {
639
+ process.on("unhandledRejection", (reason: unknown) => {
640
+ const err = reason instanceof Error ? reason : new Error(String(reason));
641
+ console.error("[crash-safety] unhandledRejection (suppressed):", err.stack || err.message);
642
+ });
643
+ process.on("uncaughtException", (err: Error) => {
644
+ console.error("[crash-safety] uncaughtException (suppressed):", err.stack || err.message);
645
+ });
646
+ }
647
+
669
648
  async function main() {
649
+ installCrashSafetyNet();
670
650
  ensureConfig();
671
651
 
672
652
  const { subcommand, flags } = parseArgs(process.argv.slice(2));