@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1

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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  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__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Filesystem helpers for locating an installed package's
3
+ * `CHANGELOG.md` and `package.json`, plus deriving a public GitHub
4
+ * URL from the `repository` field.
5
+ *
6
+ * Search order matches the ToolRegistry resolution chain for `pi`:
7
+ * 1. Managed install (`~/.pi-dashboard/node_modules/<pkg>/`)
8
+ * 2. Bare-import via `createRequire` (process resolves the package
9
+ * through its own node_modules — covers npm-global on Unix when
10
+ * the symlink lands inside this Node prefix, and dev-checkout
11
+ * paths during local builds).
12
+ *
13
+ * Both helpers return `null` rather than throwing on absence so route
14
+ * handlers can degrade to the empty-changelog response per spec
15
+ * `pi-changelog-display#Scenario: Package not installed returns empty`.
16
+ *
17
+ * See change: pi-update-whats-new-panel.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+ import { createRequire } from "node:module";
23
+
24
+ /** Default managed install root. Test seam: caller may override. */
25
+ function defaultManagedDir(): string {
26
+ return path.join(os.homedir(), ".pi-dashboard");
27
+ }
28
+
29
+ /**
30
+ * Locate a package's CHANGELOG.md on disk.
31
+ *
32
+ * Returns `{ changelogPath, packageDir }` so callers can read the
33
+ * adjacent `package.json` without re-resolving.
34
+ *
35
+ * Strategy:
36
+ * 1. `<managedDir>/node_modules/<pkg>/CHANGELOG.md` (Electron + CLI bootstrap).
37
+ * 2. `createRequire(import.meta.url).resolve("<pkg>/package.json")` then
38
+ * look for `CHANGELOG.md` next to it (covers bare-import / npm-global
39
+ * via the standard Node resolution mechanism).
40
+ *
41
+ * Returns `null` when no readable CHANGELOG can be located.
42
+ */
43
+ export interface ChangelogLocation {
44
+ changelogPath: string;
45
+ packageDir: string;
46
+ }
47
+
48
+ export interface FindOptions {
49
+ /** Override managed dir for tests. */
50
+ managedDir?: string;
51
+ /**
52
+ * Override the require-resolver used for bare-import lookup. Tests
53
+ * pass a stub that throws to force the managed path; production
54
+ * uses `createRequire(import.meta.url).resolve`.
55
+ */
56
+ resolveBareImport?: (pkgJsonSpec: string) => string;
57
+ }
58
+
59
+ export function findChangelogPath(
60
+ pkg: string,
61
+ opts: FindOptions = {},
62
+ ): ChangelogLocation | null {
63
+ // Strategy 1: managed install.
64
+ const managedDir = opts.managedDir ?? defaultManagedDir();
65
+ const managedPkg = path.join(managedDir, "node_modules", pkg);
66
+ const managedCl = path.join(managedPkg, "CHANGELOG.md");
67
+ if (fs.existsSync(managedCl)) {
68
+ return { changelogPath: managedCl, packageDir: managedPkg };
69
+ }
70
+
71
+ // Strategy 2: bare-import via require.resolve.
72
+ const resolver =
73
+ opts.resolveBareImport ??
74
+ ((spec: string) => createRequire(import.meta.url).resolve(spec));
75
+ try {
76
+ const pkgJsonPath = resolver(`${pkg}/package.json`);
77
+ const dir = path.dirname(pkgJsonPath);
78
+ const cl = path.join(dir, "CHANGELOG.md");
79
+ if (fs.existsSync(cl)) {
80
+ return { changelogPath: cl, packageDir: dir };
81
+ }
82
+ } catch {
83
+ /* not resolvable — fall through */
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Read and parse `package.json` next to a previously-located
91
+ * CHANGELOG. Returns the parsed object or `null` on read/parse error.
92
+ */
93
+ export function readPackageJson(packageDir: string): Record<string, unknown> | null {
94
+ const p = path.join(packageDir, "package.json");
95
+ try {
96
+ const raw = fs.readFileSync(p, "utf8");
97
+ const parsed = JSON.parse(raw);
98
+ if (parsed && typeof parsed === "object") {
99
+ return parsed as Record<string, unknown>;
100
+ }
101
+ return null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Derive a public GitHub URL pointing at the CHANGELOG given a
109
+ * `package.json#repository` field. Returns `null` when the field is
110
+ * missing, not GitHub-hosted, or unparseable.
111
+ *
112
+ * Accepted shapes (per spec `pi-changelog-display#Requirement: Changelog URL derivation`):
113
+ * - `"github:org/repo"` shorthand
114
+ * - `"https://github.com/org/repo.git"` URL string
115
+ * - `{ "type": "git", "url": "git+https://github.com/org/repo.git" }`
116
+ * - same object form with optional `"directory": "packages/foo"` (monorepo)
117
+ */
118
+ export function deriveChangelogUrl(repository: unknown): string | null {
119
+ if (!repository) return null;
120
+
121
+ let urlStr: string | null = null;
122
+ let directory: string | null = null;
123
+
124
+ if (typeof repository === "string") {
125
+ urlStr = repository;
126
+ } else if (typeof repository === "object" && repository !== null) {
127
+ const rec = repository as Record<string, unknown>;
128
+ if (typeof rec.url === "string") urlStr = rec.url;
129
+ if (typeof rec.directory === "string" && rec.directory.length > 0) {
130
+ directory = rec.directory.replace(/^\/+|\/+$/g, "");
131
+ }
132
+ }
133
+ if (!urlStr) return null;
134
+
135
+ const m = parseGitHubUrl(urlStr);
136
+ if (!m) return null;
137
+
138
+ const subPath = directory ? `${directory}/` : "";
139
+ return `https://github.com/${m.org}/${m.repo}/blob/main/${subPath}CHANGELOG.md`;
140
+ }
141
+
142
+ /**
143
+ * Parse the various GitHub URL forms used in `package.json#repository`
144
+ * into `{ org, repo }`. Returns null for non-GitHub or unparseable
145
+ * inputs.
146
+ */
147
+ function parseGitHubUrl(s: string): { org: string; repo: string } | null {
148
+ const trimmed = s.trim();
149
+
150
+ // github:org/repo shorthand
151
+ let m = trimmed.match(/^github:([^/]+)\/([^/#]+)/i);
152
+ if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
153
+
154
+ // git+https://github.com/org/repo.git
155
+ // https://github.com/org/repo
156
+ // git://github.com/org/repo.git
157
+ // ssh://git@github.com/org/repo.git
158
+ // git@github.com:org/repo.git
159
+ m = trimmed.match(/(?:^|[/@:])github\.com[/:]([^/]+)\/([^/#?]+)/i);
160
+ if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
161
+
162
+ return null;
163
+ }
164
+
165
+ function stripGitSuffix(repo: string): string {
166
+ return repo.replace(/\.git$/i, "");
167
+ }
@@ -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
+ }
@@ -53,6 +53,8 @@ import {
53
53
  import type { DashboardServer } from "./server.js";
54
54
  import { updateBootstrapCompatibility } from "./pi-version-skew.js";
55
55
  import type { BootstrapStateStore } from "./bootstrap-state.js";
56
+ import { parseDashboardStarter } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
57
+ import { bootstrapInstallFromList } from "./bootstrap-install-from-list.js";
56
58
 
57
59
  /**
58
60
  * Emit a stderr warning at CLI startup when the resolved pi version is
@@ -166,6 +168,12 @@ async function runForeground(config: ServerConfig): Promise<void> {
166
168
  assertNodeVersionSupported();
167
169
  const server = await createServer(config);
168
170
 
171
+ // Stamp the bootstrap state with who started this server process.
172
+ // parseDashboardStarter defaults to "Standalone" when DASHBOARD_STARTER is unset.
173
+ const starter = parseDashboardStarter(process.env);
174
+ server.bootstrapState.set({ starter });
175
+ console.log(`[bootstrap] starter=${starter}`);
176
+
169
177
  let shuttingDown = false;
170
178
  const shutdown = async () => {
171
179
  if (shuttingDown) {
@@ -181,6 +189,19 @@ async function runForeground(config: ServerConfig): Promise<void> {
181
189
  process.on("SIGINT", shutdown);
182
190
  process.on("SIGTERM", shutdown);
183
191
 
192
+ // Reconcile installable.json before binding the port.
193
+ // Required-package failures throw and prevent server start.
194
+ // Optional failures are logged and continue.
195
+ // File-absent is a no-op (Bridge/Standalone starters don't seed installable.json).
196
+ // See change: simplify-electron-bootstrap-derived-state.
197
+ try {
198
+ await bootstrapInstallFromList(server.bootstrapState);
199
+ } catch (err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ console.error(`[bootstrap] installable reconcile failed (required package): ${message}`);
202
+ process.exit(1);
203
+ }
204
+
184
205
  await server.start();
185
206
 
186
207
  // Kick off the degraded-mode first-run bootstrap if pi is unresolvable.
@@ -231,7 +252,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
231
252
  return;
232
253
  }
233
254
 
234
- const installPackages = ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"];
255
+ const installPackages = ["@earendil-works/pi-coding-agent", "@fission-ai/openspec", "tsx"];
235
256
  server.bootstrapState.setLastInstallPackages(installPackages);
236
257
  console.log("[bootstrap] installing (pi unresolved, running background install)");
237
258
  server.bootstrapState.set({
@@ -577,7 +598,7 @@ async function cmdUpgradePi(config: ServerConfig): Promise<void> {
577
598
 
578
599
  console.log("[upgrade-pi] no dashboard running — installing directly");
579
600
  const res = await bootstrapInstall({
580
- packages: ["@mariozechner/pi-coding-agent"],
601
+ packages: ["@earendil-works/pi-coding-agent"],
581
602
  progress: (p) => {
582
603
  const line = p.output
583
604
  ? `[upgrade-pi] ${p.step} ${p.status}: ${p.output}`