@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
@@ -32,6 +32,12 @@ export interface WatchdogArmOptions {
32
32
  mechanism: SpawnMechanism;
33
33
  logPath?: string;
34
34
  ws: WebSocket;
35
+ /**
36
+ * Server-minted spawn correlation token. When provided, the entry is
37
+ * indexed in `byToken` for strong-identity clearing via `clearByToken`.
38
+ * See change: spawn-correlation-token.
39
+ */
40
+ spawnToken?: string;
35
41
  }
36
42
 
37
43
  interface Entry {
@@ -42,12 +48,14 @@ interface Entry {
42
48
  logPath?: string;
43
49
  ws: WebSocket;
44
50
  timeoutMs: number;
51
+ spawnToken?: string;
45
52
  }
46
53
 
47
54
  interface RecentlyFiredEntry {
48
55
  firedAt: number;
49
56
  pid?: number;
50
57
  ws: WebSocket;
58
+ spawnToken?: string;
51
59
  }
52
60
 
53
61
  const RECENTLY_FIRED_TTL_MS = 60_000;
@@ -57,6 +65,7 @@ export class SpawnRegisterWatchdog {
57
65
  readonly timeoutMs: number;
58
66
  private readonly byPid = new Map<number, Entry>();
59
67
  private readonly byCwd = new Map<string, Entry>();
68
+ private readonly byToken = new Map<string, Entry>();
60
69
  private readonly recentlyFired = new Map<string, RecentlyFiredEntry>();
61
70
 
62
71
  constructor(timeoutMs: number) {
@@ -68,19 +77,21 @@ export class SpawnRegisterWatchdog {
68
77
  // takes effect on the next spawn without a server restart.
69
78
  // See change: spawn-failure-diagnostics (fix W1).
70
79
  const effectiveTimeout = clampSpawnRegisterTimeoutMs(opts.timeoutMs ?? this.timeoutMs);
71
- const { pid, cwd, mechanism, logPath, ws } = opts;
80
+ const { pid, cwd, mechanism, logPath, ws, spawnToken } = opts;
72
81
  const entry: Entry = {
73
82
  timer: null as unknown as ReturnType<typeof setTimeout>,
74
83
  cwd, pid, mechanism, logPath, ws,
75
84
  timeoutMs: effectiveTimeout,
85
+ spawnToken,
76
86
  };
77
87
  entry.timer = setTimeout(() => this._fireEntry(entry), effectiveTimeout);
78
88
  // Always index by cwd so a `session_register` clears the watchdog even
79
89
  // when the bridge's reported pid differs from the spawner's pid (e.g.
80
90
  // Unix headless wraps pi in `sh -c "tail -f /dev/null | pi …"`, so
81
91
  // spawnResult.pid is the sh wrapper, not pi). Index by pid additionally
82
- // for late-recovery lookup. Replace any prior entry for the same
83
- // cwd/pid to avoid leaking timers.
92
+ // for late-recovery lookup. Index by token (when provided) for
93
+ // strong-identity clearing. See change: spawn-correlation-token.
94
+ // Replace any prior entry for the same cwd/pid/token to avoid leaking timers.
84
95
  const priorCwd = this.byCwd.get(cwd);
85
96
  if (priorCwd) clearTimeout(priorCwd.timer);
86
97
  this.byCwd.set(cwd, entry);
@@ -89,6 +100,38 @@ export class SpawnRegisterWatchdog {
89
100
  if (priorPid && priorPid !== priorCwd) clearTimeout(priorPid.timer);
90
101
  this.byPid.set(pid, entry);
91
102
  }
103
+ if (spawnToken) {
104
+ const priorTok = this.byToken.get(spawnToken);
105
+ if (priorTok && priorTok !== priorCwd && priorTok !== entry) clearTimeout(priorTok.timer);
106
+ this.byToken.set(spawnToken, entry);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Strong-identity clear: cancel watchdog for this exact spawn invocation.
112
+ * Tier 1 of the three-tier match in `event-wiring.ts`. Removes the entry
113
+ * from all three indices. See change: spawn-correlation-token.
114
+ */
115
+ clearByToken(spawnToken: string): void {
116
+ const entry = this.byToken.get(spawnToken);
117
+ if (entry) {
118
+ clearTimeout(entry.timer);
119
+ this.byToken.delete(spawnToken);
120
+ const cwdEntry = this.byCwd.get(entry.cwd);
121
+ if (cwdEntry === entry) this.byCwd.delete(entry.cwd);
122
+ if (entry.pid !== undefined) {
123
+ const pidEntry = this.byPid.get(entry.pid);
124
+ if (pidEntry === entry) this.byPid.delete(entry.pid);
125
+ }
126
+ return;
127
+ }
128
+ // Check for late recovery: scan recentlyFired for matching token.
129
+ for (const [cwd, fired] of this.recentlyFired) {
130
+ if (fired.spawnToken === spawnToken) {
131
+ this._emitRecovery(cwd, fired);
132
+ return;
133
+ }
134
+ }
92
135
  }
93
136
 
94
137
  clearByPid(pid: number): void {
@@ -96,9 +139,13 @@ export class SpawnRegisterWatchdog {
96
139
  if (entry) {
97
140
  clearTimeout(entry.timer);
98
141
  this.byPid.delete(pid);
99
- // Also clear cwd entry if it points at the same arm.
142
+ // Also clear cwd / token entries if they point at the same arm.
100
143
  const cwdEntry = this.byCwd.get(entry.cwd);
101
144
  if (cwdEntry === entry) this.byCwd.delete(entry.cwd);
145
+ if (entry.spawnToken) {
146
+ const tokEntry = this.byToken.get(entry.spawnToken);
147
+ if (tokEntry === entry) this.byToken.delete(entry.spawnToken);
148
+ }
102
149
  return;
103
150
  }
104
151
  // Check for late recovery.
@@ -110,11 +157,15 @@ export class SpawnRegisterWatchdog {
110
157
  if (entry) {
111
158
  clearTimeout(entry.timer);
112
159
  this.byCwd.delete(cwd);
113
- // Also clear pid entry if it points at the same arm.
160
+ // Also clear pid / token entries if they point at the same arm.
114
161
  if (entry.pid !== undefined) {
115
162
  const pidEntry = this.byPid.get(entry.pid);
116
163
  if (pidEntry === entry) this.byPid.delete(entry.pid);
117
164
  }
165
+ if (entry.spawnToken) {
166
+ const tokEntry = this.byToken.get(entry.spawnToken);
167
+ if (tokEntry === entry) this.byToken.delete(entry.spawnToken);
168
+ }
118
169
  return;
119
170
  }
120
171
  // Check for late recovery.
@@ -131,8 +182,12 @@ export class SpawnRegisterWatchdog {
131
182
  const cwdEntry = this.byCwd.get(cwd);
132
183
  if (cwdEntry === entry) this.byCwd.delete(cwd);
133
184
 
134
- // Record in recentlyFired for late-recovery detection.
135
- this.recentlyFired.set(cwd, { firedAt: Date.now(), pid, ws });
185
+ // Record in recentlyFired for late-recovery detection (also drop token entry).
186
+ if (entry.spawnToken) {
187
+ const tokEntry = this.byToken.get(entry.spawnToken);
188
+ if (tokEntry === entry) this.byToken.delete(entry.spawnToken);
189
+ }
190
+ this.recentlyFired.set(cwd, { firedAt: Date.now(), pid, ws, spawnToken: entry.spawnToken });
136
191
 
137
192
  // Read stderr tail if logPath available.
138
193
  let stderrTail: string | undefined;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Spawn correlation token: server-minted UUIDv4 per `spawnPiSession` call.
3
+ *
4
+ * Flow: `mintSpawnToken()` → injected into spawned process env as
5
+ * `PI_DASHBOARD_SPAWN_TOKEN` → bridge reads it → bridge echoes back in
6
+ * `session_register.spawnToken` → server links via `linkByToken`.
7
+ *
8
+ * In-memory only; no persistence. TTL aligned to spawn-register-watchdog.
9
+ *
10
+ * See change: spawn-correlation-token.
11
+ */
12
+ import { randomUUID } from "node:crypto";
13
+
14
+ /** Mint a fresh UUIDv4 spawn correlation token. */
15
+ export function mintSpawnToken(): string {
16
+ return randomUUID();
17
+ }
18
+
19
+ /** Env-var name used for the correlation token in spawned pi processes. */
20
+ export const SPAWN_TOKEN_ENV_VAR = "PI_DASHBOARD_SPAWN_TOKEN";
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Tunnel watchdog: periodically probes the public tunnel URL through the
3
+ * zrok edge and recycles the tunnel when consecutive failures (5xx, network
4
+ * errors, timeouts) exceed a threshold.
5
+ *
6
+ * The zrok `share` subprocess can stay running for days while its connection
7
+ * to the zrok edge silently goes stale, returning HTTP 502 from the public
8
+ * URL even though the local upstream is healthy. The fix is a `deleteTunnel`
9
+ * + `createTunnel` cycle (preserves the reserved token, so the URL stays the
10
+ * same).
11
+ *
12
+ * Probe semantics: we treat ONLY 5xx and network/timeout failures as bad.
13
+ * Any 2xx/3xx/4xx response proves zrok edge ↔ local server connectivity is
14
+ * fine and counts as success — even if the local route is auth-gated.
15
+ */
16
+
17
+ export interface TunnelWatchdogDeps {
18
+ /** Returns the active public tunnel URL, or null if no tunnel is up. */
19
+ getUrl: () => string | null;
20
+ /** Recycle the tunnel: delete and recreate. Returns the new URL or null. */
21
+ recycle: () => Promise<string | null>;
22
+ /** Optional fetch override for tests. */
23
+ fetchFn?: typeof fetch;
24
+ /** Optional logger; defaults to console. */
25
+ log?: (level: "info" | "warn" | "error", msg: string) => void;
26
+ }
27
+
28
+ export interface TunnelWatchdogConfig {
29
+ /** Master switch. Default: true. */
30
+ enabled: boolean;
31
+ /** Probe cadence. Default: 60_000. */
32
+ intervalMs: number;
33
+ /** Consecutive failures before recycling. Default: 2. */
34
+ failureThreshold: number;
35
+ /** Per-probe HTTP timeout. Default: 10_000. */
36
+ probeTimeoutMs: number;
37
+ }
38
+
39
+ export const DEFAULT_TUNNEL_WATCHDOG_CONFIG: TunnelWatchdogConfig = {
40
+ enabled: true,
41
+ intervalMs: 60_000,
42
+ failureThreshold: 2,
43
+ probeTimeoutMs: 10_000,
44
+ };
45
+
46
+ export interface TunnelWatchdogStatus {
47
+ running: boolean;
48
+ intervalMs: number;
49
+ failureThreshold: number;
50
+ probeTimeoutMs: number;
51
+ lastProbeAt: number | null;
52
+ lastSuccessAt: number | null;
53
+ lastFailureAt: number | null;
54
+ lastFailureReason: string | null;
55
+ consecutiveFailures: number;
56
+ lastRecycleAt: number | null;
57
+ recycleCount: number;
58
+ }
59
+
60
+ interface WatchdogState {
61
+ cfg: TunnelWatchdogConfig;
62
+ deps: TunnelWatchdogDeps;
63
+ timer: ReturnType<typeof setTimeout> | null;
64
+ inFlight: boolean;
65
+ recycling: boolean;
66
+ /** Current backoff multiplier applied after a recycle failure (1, 2, 4, …, capped). */
67
+ backoffMultiplier: number;
68
+ status: TunnelWatchdogStatus;
69
+ }
70
+
71
+ let state: WatchdogState | null = null;
72
+
73
+ const MAX_BACKOFF_MULTIPLIER = 8;
74
+
75
+ function defaultLog(level: "info" | "warn" | "error", msg: string): void {
76
+ const prefix = "[tunnel-watchdog]";
77
+ if (level === "warn") console.warn(prefix, msg);
78
+ else if (level === "error") console.error(prefix, msg);
79
+ else console.log(prefix, msg);
80
+ }
81
+
82
+ function makeInitialStatus(cfg: TunnelWatchdogConfig): TunnelWatchdogStatus {
83
+ return {
84
+ running: false,
85
+ intervalMs: cfg.intervalMs,
86
+ failureThreshold: cfg.failureThreshold,
87
+ probeTimeoutMs: cfg.probeTimeoutMs,
88
+ lastProbeAt: null,
89
+ lastSuccessAt: null,
90
+ lastFailureAt: null,
91
+ lastFailureReason: null,
92
+ consecutiveFailures: 0,
93
+ lastRecycleAt: null,
94
+ recycleCount: 0,
95
+ };
96
+ }
97
+
98
+ /** Probe outcome: ok=true on 2xx/3xx/4xx, false on 5xx/network/timeout. */
99
+ export async function probeTunnel(
100
+ url: string,
101
+ timeoutMs: number,
102
+ fetchFn: typeof fetch = fetch,
103
+ ): Promise<{ ok: boolean; status?: number; reason?: string }> {
104
+ const probeUrl = url.replace(/\/+$/, "") + "/api/health";
105
+ const ctrl = new AbortController();
106
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
107
+ try {
108
+ const res = await fetchFn(probeUrl, { method: "GET", signal: ctrl.signal });
109
+ if (res.status >= 500) {
110
+ return { ok: false, status: res.status, reason: `http ${res.status}` };
111
+ }
112
+ return { ok: true, status: res.status };
113
+ } catch (err: any) {
114
+ const reason = err?.name === "AbortError" ? `timeout ${timeoutMs}ms` : (err?.message || "network error");
115
+ return { ok: false, reason };
116
+ } finally {
117
+ clearTimeout(t);
118
+ }
119
+ }
120
+
121
+ function scheduleNext(): void {
122
+ if (!state) return;
123
+ const delay = state.cfg.intervalMs * state.backoffMultiplier;
124
+ state.timer = setTimeout(() => { void tick(); }, delay);
125
+ // Don't keep the event loop alive for the watchdog alone.
126
+ if (typeof (state.timer as any).unref === "function") (state.timer as any).unref();
127
+ }
128
+
129
+ async function tick(): Promise<void> {
130
+ if (!state) return;
131
+ if (state.inFlight) { scheduleNext(); return; }
132
+ state.inFlight = true;
133
+ try {
134
+ const url = state.deps.getUrl();
135
+ if (!url) {
136
+ // No tunnel up — nothing to probe; keep ticking in case it comes up.
137
+ return;
138
+ }
139
+ const fetchFn = state.deps.fetchFn ?? fetch;
140
+ state.status.lastProbeAt = Date.now();
141
+ const result = await probeTunnel(url, state.cfg.probeTimeoutMs, fetchFn);
142
+ if (result.ok) {
143
+ state.status.lastSuccessAt = Date.now();
144
+ state.status.consecutiveFailures = 0;
145
+ state.backoffMultiplier = 1;
146
+ return;
147
+ }
148
+ state.status.lastFailureAt = Date.now();
149
+ state.status.lastFailureReason = result.reason ?? "unknown";
150
+ state.status.consecutiveFailures += 1;
151
+ (state.deps.log ?? defaultLog)(
152
+ "warn",
153
+ `probe failed (${state.status.consecutiveFailures}/${state.cfg.failureThreshold}): ${state.status.lastFailureReason}`,
154
+ );
155
+ if (state.status.consecutiveFailures >= state.cfg.failureThreshold && !state.recycling) {
156
+ await runRecycle();
157
+ }
158
+ } finally {
159
+ state.inFlight = false;
160
+ if (state) scheduleNext();
161
+ }
162
+ }
163
+
164
+ async function runRecycle(): Promise<void> {
165
+ if (!state) return;
166
+ state.recycling = true;
167
+ const log = state.deps.log ?? defaultLog;
168
+ log("warn", `recycling tunnel after ${state.status.consecutiveFailures} consecutive failures`);
169
+ try {
170
+ const newUrl = await state.deps.recycle();
171
+ state.status.lastRecycleAt = Date.now();
172
+ state.status.recycleCount += 1;
173
+ state.status.consecutiveFailures = 0;
174
+ if (newUrl) {
175
+ log("info", `tunnel recycled: ${newUrl}`);
176
+ state.backoffMultiplier = 1;
177
+ } else {
178
+ log("error", "tunnel recycle returned no URL — backing off");
179
+ state.backoffMultiplier = Math.min(state.backoffMultiplier * 2 || 2, MAX_BACKOFF_MULTIPLIER);
180
+ }
181
+ } catch (err: any) {
182
+ log("error", `tunnel recycle threw: ${err?.message ?? err}`);
183
+ state.backoffMultiplier = Math.min(state.backoffMultiplier * 2 || 2, MAX_BACKOFF_MULTIPLIER);
184
+ } finally {
185
+ state.recycling = false;
186
+ }
187
+ }
188
+
189
+ export function startTunnelWatchdog(
190
+ deps: TunnelWatchdogDeps,
191
+ cfg: Partial<TunnelWatchdogConfig> = {},
192
+ ): void {
193
+ if (state) return; // already running
194
+ const merged: TunnelWatchdogConfig = { ...DEFAULT_TUNNEL_WATCHDOG_CONFIG, ...cfg };
195
+ if (!merged.enabled) return;
196
+ state = {
197
+ cfg: merged,
198
+ deps,
199
+ timer: null,
200
+ inFlight: false,
201
+ recycling: false,
202
+ backoffMultiplier: 1,
203
+ status: makeInitialStatus(merged),
204
+ };
205
+ state.status.running = true;
206
+ scheduleNext();
207
+ }
208
+
209
+ export function stopTunnelWatchdog(): void {
210
+ if (!state) return;
211
+ if (state.timer) clearTimeout(state.timer);
212
+ state.status.running = false;
213
+ state = null;
214
+ }
215
+
216
+ export function getTunnelWatchdogStatus(): TunnelWatchdogStatus | null {
217
+ if (!state) return null;
218
+ return { ...state.status };
219
+ }
220
+
221
+ /** Test-only: force a tick now (returns when the tick completes). */
222
+ export async function _runTickForTest(): Promise<void> {
223
+ await tick();
224
+ }
225
+
226
+ /** Test-only: reset module-level state. */
227
+ export function _resetForTest(): void {
228
+ if (state?.timer) clearTimeout(state.timer);
229
+ state = null;
230
+ }
@@ -17,6 +17,7 @@ import {
17
17
 
18
18
  const zrokResolver = new ToolResolver({ processExecPath: process.execPath });
19
19
  import type { TunnelStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
20
+ import { getTunnelWatchdogStatus } from "./tunnel-watchdog.js";
20
21
  import { CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js";
21
22
 
22
23
  export type { TunnelStatus };
@@ -458,7 +459,10 @@ export function getTunnelUrl(): string | null {
458
459
  export function getTunnelStatus(): TunnelStatus {
459
460
  const serverOs = process.platform;
460
461
  if (activeTunnelUrl) {
461
- return { status: "active", url: activeTunnelUrl, serverOs };
462
+ const wd = getTunnelWatchdogStatus();
463
+ return wd
464
+ ? { status: "active", url: activeTunnelUrl, serverOs, watchdog: wd }
465
+ : { status: "active", url: activeTunnelUrl, serverOs };
462
466
  }
463
467
  if (detectZrokBinary()) {
464
468
  return { status: "inactive", serverOs };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Tests for `ToolResolver.resolveJiti` — ported from the prior
3
+ * `resolve-jiti.test.ts`. Exercises every anchor in the resolution
4
+ * chain (managed-pi upstream/legacy, system-pi, anchor walk-up,
5
+ * argv fallback, all-miss) plus the URL-shape invariants
6
+ * (`file://` URL output, Windows drive-letter wrapping, upstream
7
+ * jiti chosen before legacy fork).
8
+ *
9
+ * Test seams (`_pathExists`, `_realpath`, `_whichPi`, `_argv1`,
10
+ * `_managedDir`, `resolver`) keep the test pure — no fs / process
11
+ * mutation, no managed-dir on disk.
12
+ */
13
+ import { describe, it, expect } from "vitest";
14
+ import path from "node:path";
15
+ import { ToolResolver, MANAGED_PI_PACKAGES, JITI_PACKAGES } from "../platform/binary-lookup.js";
16
+
17
+ const MANAGED_DIR = "/fake/.pi-dashboard";
18
+
19
+ function makeResolver(installed: Record<string, string>) {
20
+ return (spec: string): string => {
21
+ if (spec in installed) return installed[spec]!;
22
+ throw new Error(`Cannot find module '${spec}'`);
23
+ };
24
+ }
25
+
26
+ describe("MANAGED_PI_PACKAGES + JITI_PACKAGES contract", () => {
27
+ it("upstream pi pkg first, legacy fork fallback", () => {
28
+ expect(MANAGED_PI_PACKAGES).toEqual([
29
+ "@earendil-works/pi-coding-agent",
30
+ "@mariozechner/pi-coding-agent",
31
+ ]);
32
+ });
33
+
34
+ it("upstream jiti first, legacy fork fallback", () => {
35
+ expect(JITI_PACKAGES).toEqual(["jiti", "@mariozechner/jiti"]);
36
+ });
37
+ });
38
+
39
+ describe("ToolResolver.resolveJiti — managed pi", () => {
40
+ it("hits upstream managed pi (@earendil-works) when only it is present", () => {
41
+ const upstreamPkgJson = path.join(
42
+ MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json",
43
+ );
44
+ const jitiPkgJson = "/managed/upstream/node_modules/jiti/package.json";
45
+ const url = new ToolResolver().resolveJiti({
46
+ _managedDir: MANAGED_DIR,
47
+ _pathExists: (p) => p === upstreamPkgJson || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
48
+ _whichPi: () => null,
49
+ _argv1: undefined,
50
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
51
+ });
52
+ expect(url).not.toBeNull();
53
+ expect(url!.startsWith("file://")).toBe(true);
54
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
55
+ expect(url!).not.toContain("@mariozechner");
56
+ });
57
+
58
+ it("falls through to legacy managed pi (@mariozechner) when upstream is absent", () => {
59
+ const legacyPkgJson = path.join(
60
+ MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json",
61
+ );
62
+ const jitiPkgJson = "/managed/legacy/node_modules/@mariozechner/jiti/package.json";
63
+ const url = new ToolResolver().resolveJiti({
64
+ _managedDir: MANAGED_DIR,
65
+ _pathExists: (p) =>
66
+ p === legacyPkgJson ||
67
+ p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
68
+ _whichPi: () => null,
69
+ _argv1: undefined,
70
+ resolver: makeResolver({
71
+ "@mariozechner/jiti/package.json": jitiPkgJson,
72
+ }),
73
+ });
74
+ expect(url).not.toBeNull();
75
+ expect(url!).toContain("@mariozechner/jiti");
76
+ });
77
+
78
+ it("prefers upstream pi over legacy when BOTH managed pkgs are present", () => {
79
+ const upstream = path.join(MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
80
+ const legacy = path.join(MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
81
+ const upstreamJiti = "/managed/upstream/jiti/package.json";
82
+ const legacyJiti = "/managed/legacy/@mariozechner/jiti/package.json";
83
+ const calls: string[] = [];
84
+ const resolver = (spec: string): string => {
85
+ calls.push(spec);
86
+ if (spec === "jiti/package.json") return upstreamJiti;
87
+ if (spec === "@mariozechner/jiti/package.json") return legacyJiti;
88
+ throw new Error(`nope ${spec}`);
89
+ };
90
+ const url = new ToolResolver().resolveJiti({
91
+ _managedDir: MANAGED_DIR,
92
+ _pathExists: (p) =>
93
+ p === upstream || p === legacy ||
94
+ p === path.join(path.dirname(upstreamJiti), "lib", "jiti-register.mjs"),
95
+ _whichPi: () => null,
96
+ _argv1: undefined,
97
+ resolver,
98
+ });
99
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
100
+ expect(url!).not.toContain("@mariozechner");
101
+ // Upstream pi anchor produced upstream jiti — legacy pi anchor never tried.
102
+ expect(calls).toEqual(["jiti/package.json"]);
103
+ });
104
+ });
105
+
106
+ describe("ToolResolver.resolveJiti — system pi", () => {
107
+ it("uses which(\"pi\") when managed pi absent", () => {
108
+ const piBin = "/usr/local/bin/pi";
109
+ const piReal = "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js";
110
+ const jitiPkgJson = "/usr/local/lib/node_modules/jiti/package.json";
111
+ const url = new ToolResolver().resolveJiti({
112
+ _managedDir: MANAGED_DIR,
113
+ _pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
114
+ _whichPi: () => piBin,
115
+ _realpath: (p) => (p === piBin ? piReal : p),
116
+ _argv1: undefined,
117
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
118
+ });
119
+ expect(url!.startsWith("file://")).toBe(true);
120
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
121
+ });
122
+
123
+ it("realpaths a symlinked pi binary before resolving", () => {
124
+ const piSymlink = "/usr/local/bin/pi";
125
+ const piTarget = "/opt/pi/dist/cli.js";
126
+ const jitiPkgJson = "/opt/pi/node_modules/jiti/package.json";
127
+ let realpathArg: string | null = null;
128
+ const url = new ToolResolver().resolveJiti({
129
+ _managedDir: MANAGED_DIR,
130
+ // Managed-pi miss; only the symlinked register file exists.
131
+ _pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
132
+ _whichPi: () => piSymlink,
133
+ _realpath: (p) => { realpathArg = p; return piTarget; },
134
+ _argv1: undefined,
135
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
136
+ });
137
+ expect(realpathArg).toBe(piSymlink);
138
+ expect(url).not.toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("ToolResolver.resolveJiti — anchor walk-up + argv fallback", () => {
143
+ it("uses caller-supplied anchor when prior layers miss", () => {
144
+ const anchor = "/custom/cli/path.js";
145
+ const jitiPkgJson = "/custom/node_modules/jiti/package.json";
146
+ const url = new ToolResolver().resolveJiti({
147
+ anchor,
148
+ _managedDir: MANAGED_DIR,
149
+ _pathExists: (p) => p === anchor || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
150
+ _whichPi: () => null,
151
+ _argv1: undefined,
152
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
153
+ });
154
+ expect(url).not.toBeNull();
155
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
156
+ });
157
+
158
+ it("returns null when caller-supplied anchor does not exist on disk", () => {
159
+ const url = new ToolResolver().resolveJiti({
160
+ anchor: "/missing/path.js",
161
+ _managedDir: MANAGED_DIR,
162
+ _pathExists: () => false,
163
+ _whichPi: () => null,
164
+ _argv1: undefined,
165
+ resolver: () => "/whatever/jiti/package.json",
166
+ });
167
+ expect(url).toBeNull();
168
+ });
169
+
170
+ it("falls back to process.argv[1] (test seam) when all earlier anchors miss", () => {
171
+ const argv = "/runtime/argv1/cli.js";
172
+ const jitiPkgJson = "/runtime/node_modules/jiti/package.json";
173
+ const url = new ToolResolver().resolveJiti({
174
+ _managedDir: MANAGED_DIR,
175
+ _pathExists: () => true,
176
+ _whichPi: () => null,
177
+ _realpath: (p) => p,
178
+ _argv1: argv,
179
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
180
+ });
181
+ expect(url).not.toBeNull();
182
+ });
183
+
184
+ it("returns null when every anchor misses", () => {
185
+ const url = new ToolResolver().resolveJiti({
186
+ _managedDir: MANAGED_DIR,
187
+ _pathExists: () => false,
188
+ _whichPi: () => null,
189
+ _argv1: undefined,
190
+ resolver: () => { throw new Error("nope"); },
191
+ });
192
+ expect(url).toBeNull();
193
+ });
194
+ });
195
+
196
+ describe("ToolResolver.resolveJiti — URL contract", () => {
197
+ it("returns a file:// URL parseable by new URL()", () => {
198
+ const url = new ToolResolver().resolveJiti({
199
+ _managedDir: MANAGED_DIR,
200
+ _pathExists: () => true,
201
+ _whichPi: () => null,
202
+ _argv1: "/runtime/argv1/cli.js",
203
+ _realpath: (p) => p,
204
+ resolver: makeResolver({ "jiti/package.json": "/r/node_modules/jiti/package.json" }),
205
+ });
206
+ expect(url!.startsWith("file://")).toBe(true);
207
+ expect(() => new URL(url!)).not.toThrow();
208
+ expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
209
+ });
210
+
211
+ it("URL-wraps Windows drive-letter pkg.json paths (regression for ERR_UNSUPPORTED_ESM_URL_SCHEME)", () => {
212
+ const winPkgJson = "B:\\Dev\\Nodejs\\global\\node_modules\\@mariozechner\\jiti\\package.json";
213
+ const url = new ToolResolver().resolveJiti({
214
+ _managedDir: MANAGED_DIR,
215
+ _pathExists: () => true,
216
+ _whichPi: () => null,
217
+ _argv1: "C:\\runtime\\cli.js",
218
+ _realpath: (p) => p,
219
+ resolver: makeResolver({ "@mariozechner/jiti/package.json": winPkgJson }),
220
+ });
221
+ expect(url).not.toBeNull();
222
+ expect(url!.startsWith("file:///")).toBe(true);
223
+ expect(() => new URL(url!)).not.toThrow();
224
+ expect(new URL(url!).protocol).toBe("file:");
225
+ expect(url!.toLowerCase()).toContain("/b:/");
226
+ expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
227
+ });
228
+ });