@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,330 @@
1
+ /**
2
+ * Per-HOME advisory lock for the dashboard server.
3
+ *
4
+ * Ensures one dashboard instance per HOME (`<realpath(os.homedir())>/.pi/`).
5
+ * See change: single-dashboard-per-home.
6
+ *
7
+ * Responsibilities:
8
+ * - Canonicalize HOME (avoid symlink/Git-Bash drift)
9
+ * - Acquire the lock via `proper-lockfile` (non-blocking, stale-aware)
10
+ * - Write / read an atomic metadata sidecar
11
+ * - Verify a held lock's liveness via identity-checked health probe
12
+ * - Return an `acquired` or `attach` result for the caller to dispatch
13
+ *
14
+ * Signal handlers and release-on-exit plumbing live in
15
+ * `home-lock-release.ts` to keep this module pure + testable.
16
+ */
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { randomUUID } from "node:crypto";
21
+ import properLockfile from "proper-lockfile";
22
+ import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
23
+ import { isProcessAlive } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
24
+ /** Thrown when port is held by an unrelated process. Non-fatal to this
25
+ * module; caller decides (exit with message / retry / override). */
26
+ export class InstanceLockMismatchError extends Error {
27
+ meta;
28
+ observedIdentity;
29
+ code = "E_INSTANCE_MISMATCH";
30
+ constructor(meta, observedIdentity) {
31
+ super(`Port ${meta.httpPort} is in use by an unrelated process (PID ${meta.pid}). ` +
32
+ `Configure a different port or stop that process.`);
33
+ this.meta = meta;
34
+ this.observedIdentity = observedIdentity;
35
+ }
36
+ }
37
+ // ──────────────────────────────────────────────────────────
38
+ // Paths
39
+ // ──────────────────────────────────────────────────────────
40
+ /**
41
+ * Canonical HOME directory.
42
+ *
43
+ * Uses `os.userInfo().homedir` in preference to `os.homedir()` because on
44
+ * POSIX the latter honors the `$HOME` environment variable (Node docs say:
45
+ * "On POSIX, it uses the `$HOME` environment variable if defined"), which
46
+ * the design (§4) explicitly prohibits — a GUI-launched process and a
47
+ * shell-launched process would otherwise disagree on "where HOME is".
48
+ * `userInfo().homedir` consults `getpwuid(3)` on POSIX, immune to `$HOME`.
49
+ *
50
+ * On Windows, both APIs ultimately use `USERPROFILE`, so the Git Bash
51
+ * drift case (`$HOME=/c/Users/R` vs `USERPROFILE=C:\Users\R`) is handled
52
+ * either way; keeping `userInfo().homedir` first is still correct.
53
+ *
54
+ * Result is then passed through `fs.realpathSync` to collapse symlinks,
55
+ * FileVault migrations, and other canonicalization drift. Tolerant: falls
56
+ * back to the raw path if realpath fails.
57
+ */
58
+ export function canonicalHomedir() {
59
+ let raw;
60
+ try {
61
+ raw = os.userInfo().homedir;
62
+ }
63
+ catch {
64
+ raw = os.homedir();
65
+ }
66
+ try {
67
+ return fs.realpathSync(raw);
68
+ }
69
+ catch {
70
+ return raw;
71
+ }
72
+ }
73
+ /**
74
+ * Lock file path. This is what `proper-lockfile` locks.
75
+ */
76
+ export function getLockPath(homedir = canonicalHomedir()) {
77
+ return path.join(homedir, ".pi", "dashboard", "server.lock");
78
+ }
79
+ /**
80
+ * Metadata sidecar path (`<lockPath>.meta.json`).
81
+ */
82
+ export function getMetaPath(lockPath = getLockPath()) {
83
+ return `${lockPath}.meta.json`;
84
+ }
85
+ // ──────────────────────────────────────────────────────────
86
+ // Metadata I/O
87
+ // ──────────────────────────────────────────────────────────
88
+ /**
89
+ * Atomically write the metadata sidecar via tmp + rename.
90
+ * Never leaves a partial file visible.
91
+ */
92
+ export function writeMetadataAtomic(meta, metaPath = getMetaPath()) {
93
+ const dir = path.dirname(metaPath);
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ const tmpPath = `${metaPath}.tmp-${process.pid}-${Date.now()}`;
96
+ fs.writeFileSync(tmpPath, JSON.stringify(meta, null, 2));
97
+ fs.renameSync(tmpPath, metaPath);
98
+ }
99
+ /**
100
+ * Read the metadata sidecar. Returns null on any failure (missing, corrupt,
101
+ * permission-denied). Callers MUST treat null as "assume stale."
102
+ */
103
+ export function readMetadata(metaPath = getMetaPath()) {
104
+ try {
105
+ const raw = fs.readFileSync(metaPath, "utf-8");
106
+ const parsed = JSON.parse(raw);
107
+ if (!isLockMetadata(parsed))
108
+ return null;
109
+ return parsed;
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ function isLockMetadata(value) {
116
+ if (!value || typeof value !== "object")
117
+ return false;
118
+ const m = value;
119
+ return (typeof m.pid === "number" &&
120
+ typeof m.httpPort === "number" &&
121
+ typeof m.piPort === "number" &&
122
+ typeof m.startedAt === "number" &&
123
+ typeof m.identity === "string" &&
124
+ typeof m.version === "string" &&
125
+ typeof m.url === "string");
126
+ }
127
+ /**
128
+ * Remove the metadata sidecar. Silent on any error (missing is fine).
129
+ */
130
+ export function removeMetadata(metaPath = getMetaPath()) {
131
+ try {
132
+ fs.unlinkSync(metaPath);
133
+ }
134
+ catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+ // ──────────────────────────────────────────────────────────
139
+ // Liveness
140
+ // ──────────────────────────────────────────────────────────
141
+ /**
142
+ * Determine if the recorded lock holder is a responsive, identity-matching
143
+ * dashboard. Returns:
144
+ * - `"alive-match"`: attach to it
145
+ * - `"alive-mismatch"`: someone else is on that port
146
+ * - `"dead"`: treat as stale, proceed to acquire
147
+ */
148
+ export async function isLockHolderResponsive(meta, hooks = {}) {
149
+ const aliveCheck = hooks.isProcessAlive ?? isProcessAlive;
150
+ if (!aliveCheck(meta.pid))
151
+ return "dead";
152
+ const probe = hooks.probeHealth ?? defaultProbeHealth;
153
+ const res = await probe(meta.httpPort);
154
+ if (!res || !res.running)
155
+ return "dead";
156
+ // Identity check: `identity` field is preferred; fall back to PID match
157
+ // to stay compatible with older dashboards that predate identity.
158
+ if (res.identity) {
159
+ return res.identity === meta.identity ? "alive-match" : "alive-mismatch";
160
+ }
161
+ if (typeof res.pid === "number") {
162
+ return res.pid === meta.pid ? "alive-match" : "alive-mismatch";
163
+ }
164
+ // Running but no verifiable identity — conservative: mismatch.
165
+ return "alive-mismatch";
166
+ }
167
+ async function defaultProbeHealth(port) {
168
+ const status = await isDashboardRunning(port);
169
+ if (!status.running)
170
+ return { running: false };
171
+ // `isDashboardRunning` doesn't expose identity today. Re-fetch to peek at
172
+ // the full health body for the `identity` field. Best-effort.
173
+ try {
174
+ const res = await fetch(`http://localhost:${port}/api/health`, {
175
+ signal: AbortSignal.timeout(1500),
176
+ });
177
+ if (res.ok) {
178
+ const body = (await res.json());
179
+ return { running: true, pid: body.pid, identity: body.identity };
180
+ }
181
+ }
182
+ catch {
183
+ /* fall through */
184
+ }
185
+ return { running: true, pid: status.pid };
186
+ }
187
+ // ──────────────────────────────────────────────────────────
188
+ // Acquire
189
+ // ──────────────────────────────────────────────────────────
190
+ /**
191
+ * Acquire the per-HOME lock, or fall back to attach semantics if a live
192
+ * dashboard already holds it.
193
+ *
194
+ * Flow:
195
+ * 1. Ensure `~/.pi/dashboard/` exists (proper-lockfile requires parent).
196
+ * 2. `proper-lockfile.lock(path, { stale, retries: 0 })`
197
+ * ↪ on success: write metadata, return { mode: "acquired", release }
198
+ * ↪ on ELOCKED: read metadata, check liveness
199
+ * - dead: steal via `proper-lockfile.lock({ realpath:false, stale: 0 })`
200
+ * (Note: proper-lockfile already does stale-stealing when
201
+ * `stale` is configured — we just retry once.)
202
+ * - alive-match: return { mode: "attach", meta }
203
+ * - alive-mismatch: throw InstanceLockMismatchError
204
+ */
205
+ export async function acquireOrAttach(config) {
206
+ const hooks = config.hooks ?? {};
207
+ const lockPath = hooks.lockPath ?? getLockPath();
208
+ const metaPath = hooks.metaPath ?? getMetaPath(lockPath);
209
+ const staleMs = hooks.staleMs ?? 10_000;
210
+ const now = hooks.now ?? Date.now;
211
+ const hostname = hooks.hostname ?? os.hostname;
212
+ // Ensure the lock file's parent directory exists. proper-lockfile wants
213
+ // either the target file (which it creates alongside as `<path>.lock/`)
214
+ // or an existing file — we create an empty sentinel so the API is
215
+ // deterministic.
216
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
217
+ if (!fs.existsSync(lockPath)) {
218
+ fs.writeFileSync(lockPath, "# pi-dashboard per-HOME advisory lock\n");
219
+ }
220
+ const buildMeta = () => ({
221
+ pid: process.pid,
222
+ ppid: process.ppid,
223
+ httpPort: config.httpPort,
224
+ piPort: config.piPort,
225
+ startedAt: now(),
226
+ identity: config.identity ?? randomUUID(),
227
+ version: config.version,
228
+ url: `http://localhost:${config.httpPort}`,
229
+ hostname: hostname(),
230
+ });
231
+ const tryAcquire = async () => {
232
+ const release = await properLockfile.lock(lockPath, {
233
+ stale: staleMs,
234
+ retries: 0,
235
+ // proper-lockfile uses realpath by default; we already pass a
236
+ // realpath-based directory, so this is a no-op but kept explicit.
237
+ realpath: false,
238
+ });
239
+ const meta = buildMeta();
240
+ writeMetadataAtomic(meta, metaPath);
241
+ const releaseOnce = (() => {
242
+ let released = false;
243
+ return async () => {
244
+ if (released)
245
+ return;
246
+ released = true;
247
+ try {
248
+ await release();
249
+ }
250
+ catch {
251
+ /* ignore — lock may have been compromised */
252
+ }
253
+ removeMetadata(metaPath);
254
+ };
255
+ })();
256
+ return { mode: "acquired", meta, release: releaseOnce };
257
+ };
258
+ try {
259
+ return await tryAcquire();
260
+ }
261
+ catch (err) {
262
+ if (!isELocked(err))
263
+ throw err;
264
+ // Someone else holds the lock. Decide: attach or error.
265
+ //
266
+ // Concurrent-launch race: if two callers race, the winner writes the
267
+ // metadata sidecar a few ms after acquiring. The loser hits ELOCKED
268
+ // faster and can read the sidecar BEFORE the winner has written it.
269
+ // Short-poll for metadata to land before concluding "no metadata = stale."
270
+ let meta = null;
271
+ for (let i = 0; i < 20; i++) {
272
+ meta = readMetadata(metaPath);
273
+ if (meta)
274
+ break;
275
+ await new Promise(r => setTimeout(r, 25));
276
+ }
277
+ if (!meta) {
278
+ // Truly no metadata after 500ms → assume stale/corrupt. Force steal.
279
+ removeMetadata(metaPath);
280
+ try {
281
+ return await tryAcquire();
282
+ }
283
+ catch (err2) {
284
+ if (!isELocked(err2))
285
+ throw err2;
286
+ try {
287
+ await properLockfile.unlock(lockPath, { realpath: false });
288
+ }
289
+ catch {
290
+ /* ignore */
291
+ }
292
+ return await tryAcquire();
293
+ }
294
+ }
295
+ const liveness = await isLockHolderResponsive(meta, hooks);
296
+ if (liveness === "alive-match") {
297
+ return { mode: "attach", meta };
298
+ }
299
+ if (liveness === "alive-mismatch") {
300
+ throw new InstanceLockMismatchError(meta, null);
301
+ }
302
+ // Dead holder — steal.
303
+ try {
304
+ await properLockfile.unlock(lockPath, { realpath: false });
305
+ }
306
+ catch {
307
+ /* ignore */
308
+ }
309
+ removeMetadata(metaPath);
310
+ return await tryAcquire();
311
+ }
312
+ }
313
+ function isELocked(err) {
314
+ if (!err || typeof err !== "object")
315
+ return false;
316
+ const code = err.code;
317
+ return code === "ELOCKED";
318
+ }
319
+ // ──────────────────────────────────────────────────────────
320
+ // Escape hatch
321
+ // ──────────────────────────────────────────────────────────
322
+ /**
323
+ * True when the user has opted out of the per-HOME lock. Caller should
324
+ * log a warning and skip acquireOrAttach when set.
325
+ */
326
+ export function isLockDisabled(env = process.env) {
327
+ const raw = env.PI_DASHBOARD_ALLOW_MULTIPLE;
328
+ return raw === "1" || raw === "true";
329
+ }
330
+ //# sourceMappingURL=home-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"home-lock.js","sourceRoot":"","sources":["home-lock.ts"],"names":[],"mappings":"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"}
@@ -12,9 +12,19 @@ export interface IdleTimer {
12
12
  setStopFn(fn: () => Promise<void>): void;
13
13
  }
14
14
 
15
+ /**
16
+ * Predicate that returns true when one or more terminal PTYs are alive.
17
+ * The idle timer SHALL NOT shut down while terminals are active — a user
18
+ * with a long-running command (cargo build, tail -f, etc.) and no
19
+ * attached agent still expects the server to stay up.
20
+ * See change: fix-terminal-half-height-dual-mount.
21
+ */
22
+ export type HasActiveTerminals = () => boolean;
23
+
15
24
  export function createIdleTimer(
16
25
  config: ServerConfig,
17
26
  piGateway: PiGateway,
27
+ hasActiveTerminals: HasActiveTerminals = () => false,
18
28
  ): IdleTimer {
19
29
  let idleTimer: ReturnType<typeof setTimeout> | null = null;
20
30
  let stopServer: (() => Promise<void>) | null = null;
@@ -25,7 +35,11 @@ export function createIdleTimer(
25
35
  cancel();
26
36
  idleTimer = setTimeout(async () => {
27
37
  const realIdleMs = Date.now() - lastConnectionTimestamp;
28
- if (piGateway.connectionCount() > 0 || realIdleMs < config.shutdownIdleSeconds * 1000) {
38
+ if (
39
+ piGateway.connectionCount() > 0 ||
40
+ realIdleMs < config.shutdownIdleSeconds * 1000 ||
41
+ hasActiveTerminals()
42
+ ) {
29
43
  start();
30
44
  return;
31
45
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Maps `spawnToken` (server-minted UUID) → `requestId` (client-minted UUID).
3
+ *
4
+ * Recorded by `handleSpawnSession` / `handleResumeSession` when the browser
5
+ * supplied a `requestId`. Consumed by `event-wiring.ts` after a successful
6
+ * `linkByToken` so the eventual `session_added` broadcast can carry
7
+ * `spawnRequestId` for client-side auto-select / placeholder dismissal.
8
+ *
9
+ * In-memory only. 60s TTL aligned with `spawn-register-watchdog` recovery
10
+ * window so late registers can still surface the correlation.
11
+ *
12
+ * See change: spawn-correlation-token.
13
+ */
14
+
15
+ const DEFAULT_TTL_MS = 60_000;
16
+
17
+ interface Entry {
18
+ requestId: string;
19
+ recordedAt: number;
20
+ timer: ReturnType<typeof setTimeout>;
21
+ }
22
+
23
+ export interface PendingClientCorrelations {
24
+ /** Record `spawnToken → requestId` mapping. Overwrites any prior entry for the same token. */
25
+ record(spawnToken: string, requestId: string): void;
26
+ /** Consume the requestId for a spawnToken, or undefined if none / expired. */
27
+ consume(spawnToken: string): string | undefined;
28
+ /** Drop all entries (server shutdown / tests). */
29
+ dispose(): void;
30
+ /** Number of tracked entries (for tests). */
31
+ size(): number;
32
+ }
33
+
34
+ export interface PendingClientCorrelationsOptions {
35
+ ttlMs?: number;
36
+ }
37
+
38
+ export function createPendingClientCorrelations(
39
+ options?: PendingClientCorrelationsOptions,
40
+ ): PendingClientCorrelations {
41
+ const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
42
+ const store = new Map<string, Entry>();
43
+
44
+ return {
45
+ record(spawnToken: string, requestId: string): void {
46
+ if (!spawnToken || !requestId) return;
47
+ const prior = store.get(spawnToken);
48
+ if (prior) clearTimeout(prior.timer);
49
+ const timer = setTimeout(() => {
50
+ store.delete(spawnToken);
51
+ }, ttlMs);
52
+ store.set(spawnToken, { requestId, recordedAt: Date.now(), timer });
53
+ },
54
+
55
+ consume(spawnToken: string): string | undefined {
56
+ if (!spawnToken) return undefined;
57
+ const entry = store.get(spawnToken);
58
+ if (!entry) return undefined;
59
+ clearTimeout(entry.timer);
60
+ store.delete(spawnToken);
61
+ return entry.requestId;
62
+ },
63
+
64
+ dispose(): void {
65
+ for (const entry of store.values()) clearTimeout(entry.timer);
66
+ store.clear();
67
+ },
68
+
69
+ size(): number {
70
+ return store.size;
71
+ },
72
+ };
73
+ }
@@ -1,6 +1,16 @@
1
1
  /**
2
2
  * Tracks pending fork operations to place forked sessions after their parent.
3
+ *
4
+ * Keyed by `spawnToken` (UUID minted at fork time). When the forked session's
5
+ * bridge sends `session_register.spawnToken`, the server consumes the entry
6
+ * and places the new sessionId after `parentSessionId` in the cwd's order.
7
+ *
8
+ * Replaces the prior cwd-FIFO keying which suffered the multi-fork-in-same-cwd
9
+ * race (second fork's `recordFork` would overwrite the first).
10
+ *
3
11
  * Entries expire after 30 seconds if not consumed.
12
+ *
13
+ * See change: spawn-correlation-token.
4
14
  */
5
15
 
6
16
  const EXPIRY_MS = 30_000;
@@ -11,10 +21,10 @@ interface PendingFork {
11
21
  }
12
22
 
13
23
  export interface PendingForkRegistry {
14
- /** Record that a fork was initiated from parentSessionId in the given cwd. */
15
- recordFork(cwd: string, parentSessionId: string): void;
16
- /** Consume and return the parent session ID for a cwd, or undefined if none pending. */
17
- consumeFork(cwd: string): string | undefined;
24
+ /** Record that a fork was initiated from `parentSessionId`, keyed by the spawn token. */
25
+ recordFork(spawnToken: string, parentSessionId: string): void;
26
+ /** Consume the parent session id for a spawn token, or undefined if none pending. */
27
+ consumeFork(spawnToken: string): string | undefined;
18
28
  /** Clear all pending entries and timers. */
19
29
  dispose(): void;
20
30
  }
@@ -23,23 +33,25 @@ export function createPendingForkRegistry(): PendingForkRegistry {
23
33
  const pending = new Map<string, PendingFork>();
24
34
 
25
35
  return {
26
- recordFork(cwd: string, parentSessionId: string): void {
27
- // Clear previous entry for same cwd
28
- const existing = pending.get(cwd);
36
+ recordFork(spawnToken: string, parentSessionId: string): void {
37
+ if (!spawnToken) return;
38
+ // Clear any prior entry for the same token (idempotent re-record).
39
+ const existing = pending.get(spawnToken);
29
40
  if (existing) {
30
41
  clearTimeout(existing.timer);
31
42
  }
32
43
  const timer = setTimeout(() => {
33
- pending.delete(cwd);
44
+ pending.delete(spawnToken);
34
45
  }, EXPIRY_MS);
35
- pending.set(cwd, { parentSessionId, timer });
46
+ pending.set(spawnToken, { parentSessionId, timer });
36
47
  },
37
48
 
38
- consumeFork(cwd: string): string | undefined {
39
- const entry = pending.get(cwd);
49
+ consumeFork(spawnToken: string): string | undefined {
50
+ if (!spawnToken) return undefined;
51
+ const entry = pending.get(spawnToken);
40
52
  if (!entry) return undefined;
41
53
  clearTimeout(entry.timer);
42
- pending.delete(cwd);
54
+ pending.delete(spawnToken);
43
55
  return entry.parentSessionId;
44
56
  },
45
57