jfl 0.9.1 → 0.9.3

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 (126) hide show
  1. package/dist/commands/context-hub.d.ts.map +1 -1
  2. package/dist/commands/context-hub.js +141 -3
  3. package/dist/commands/context-hub.js.map +1 -1
  4. package/dist/commands/ide.d.ts.map +1 -1
  5. package/dist/commands/ide.js +22 -0
  6. package/dist/commands/ide.js.map +1 -1
  7. package/dist/commands/init.d.ts.map +1 -1
  8. package/dist/commands/init.js +6 -0
  9. package/dist/commands/init.js.map +1 -1
  10. package/dist/commands/linear.d.ts.map +1 -1
  11. package/dist/commands/linear.js +24 -0
  12. package/dist/commands/linear.js.map +1 -1
  13. package/dist/commands/peter.d.ts.map +1 -1
  14. package/dist/commands/peter.js +11 -15
  15. package/dist/commands/peter.js.map +1 -1
  16. package/dist/commands/pi.d.ts +3 -0
  17. package/dist/commands/pi.d.ts.map +1 -1
  18. package/dist/commands/pi.js +19 -0
  19. package/dist/commands/pi.js.map +1 -1
  20. package/dist/commands/pivot.d.ts.map +1 -1
  21. package/dist/commands/pivot.js +22 -25
  22. package/dist/commands/pivot.js.map +1 -1
  23. package/dist/commands/repair.d.ts.map +1 -1
  24. package/dist/commands/repair.js +26 -0
  25. package/dist/commands/repair.js.map +1 -1
  26. package/dist/commands/session.d.ts.map +1 -1
  27. package/dist/commands/session.js +39 -0
  28. package/dist/commands/session.js.map +1 -1
  29. package/dist/commands/start.d.ts.map +1 -1
  30. package/dist/commands/start.js +60 -0
  31. package/dist/commands/start.js.map +1 -1
  32. package/dist/commands/update.d.ts.map +1 -1
  33. package/dist/commands/update.js +3 -1
  34. package/dist/commands/update.js.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/lib/advanced-setup.js +7 -7
  38. package/dist/lib/advanced-setup.js.map +1 -1
  39. package/dist/lib/agent-session.d.ts.map +1 -1
  40. package/dist/lib/agent-session.js +6 -3
  41. package/dist/lib/agent-session.js.map +1 -1
  42. package/dist/lib/discovery-agent.js +1 -1
  43. package/dist/lib/discovery-agent.js.map +1 -1
  44. package/dist/lib/gtm-generator.js +7 -0
  45. package/dist/lib/gtm-generator.js.map +1 -1
  46. package/dist/lib/linear-webhook.d.ts +50 -0
  47. package/dist/lib/linear-webhook.d.ts.map +1 -0
  48. package/dist/lib/linear-webhook.js +92 -0
  49. package/dist/lib/linear-webhook.js.map +1 -0
  50. package/dist/lib/memory-db.d.ts +8 -0
  51. package/dist/lib/memory-db.d.ts.map +1 -1
  52. package/dist/lib/memory-db.js +24 -0
  53. package/dist/lib/memory-db.js.map +1 -1
  54. package/dist/lib/memory-indexer.d.ts +8 -0
  55. package/dist/lib/memory-indexer.d.ts.map +1 -1
  56. package/dist/lib/memory-indexer.js +30 -1
  57. package/dist/lib/memory-indexer.js.map +1 -1
  58. package/dist/lib/memory-search.d.ts.map +1 -1
  59. package/dist/lib/memory-search.js +2 -7
  60. package/dist/lib/memory-search.js.map +1 -1
  61. package/dist/lib/onboarding.js +1 -1
  62. package/dist/lib/onboarding.js.map +1 -1
  63. package/dist/lib/rl-manager.d.ts +1 -1
  64. package/dist/lib/rl-manager.d.ts.map +1 -1
  65. package/dist/lib/rl-manager.js +3 -3
  66. package/dist/lib/rl-manager.js.map +1 -1
  67. package/dist/lib/service-detector.js +2 -2
  68. package/dist/lib/service-detector.js.map +1 -1
  69. package/dist/lib/telemetry/physical-world-collector.js +1 -1
  70. package/dist/lib/telemetry/physical-world-collector.js.map +1 -1
  71. package/dist/lib/tool-schemas.d.ts +35 -0
  72. package/dist/lib/tool-schemas.d.ts.map +1 -0
  73. package/dist/lib/tool-schemas.js +246 -0
  74. package/dist/lib/tool-schemas.js.map +1 -0
  75. package/dist/lib/workspace/data-pipeline.d.ts.map +1 -1
  76. package/dist/lib/workspace/data-pipeline.js +29 -20
  77. package/dist/lib/workspace/data-pipeline.js.map +1 -1
  78. package/dist/lib/workspace/engine.d.ts +1 -0
  79. package/dist/lib/workspace/engine.d.ts.map +1 -1
  80. package/dist/lib/workspace/engine.js +10 -0
  81. package/dist/lib/workspace/engine.js.map +1 -1
  82. package/dist/mcp/context-hub-mcp.js +7 -1
  83. package/dist/mcp/context-hub-mcp.js.map +1 -1
  84. package/dist/types/telemetry.d.ts +1 -0
  85. package/dist/types/telemetry.d.ts.map +1 -1
  86. package/dist/utils/git.d.ts +1 -1
  87. package/dist/utils/git.d.ts.map +1 -1
  88. package/dist/utils/git.js +9 -6
  89. package/dist/utils/git.js.map +1 -1
  90. package/dist/utils/provenance.d.ts +65 -0
  91. package/dist/utils/provenance.d.ts.map +1 -0
  92. package/dist/utils/provenance.js +213 -0
  93. package/dist/utils/provenance.js.map +1 -0
  94. package/package.json +1 -1
  95. package/packages/pi/assets/boot.mp3 +0 -0
  96. package/packages/pi/extensions/autoresearch.ts +3 -2
  97. package/packages/pi/extensions/context.ts +38 -114
  98. package/packages/pi/extensions/eval.ts +2 -1
  99. package/packages/pi/extensions/header.ts +171 -0
  100. package/packages/pi/extensions/hub-tools.ts +31 -11
  101. package/packages/pi/extensions/hud-tool.ts +231 -70
  102. package/packages/pi/extensions/index.ts +65 -64
  103. package/packages/pi/extensions/jfl-resolve.ts +98 -0
  104. package/packages/pi/extensions/journal.ts +91 -6
  105. package/packages/pi/extensions/map-bridge.ts +31 -0
  106. package/packages/pi/extensions/memory-tool.ts +3 -3
  107. package/packages/pi/extensions/onboarding-v2.ts +263 -410
  108. package/packages/pi/extensions/onboarding-v3.ts +32 -21
  109. package/packages/pi/extensions/peter-parker.ts +2 -1
  110. package/packages/pi/extensions/policy-head-tool.ts +3 -2
  111. package/packages/pi/extensions/portfolio-bridge.ts +3 -4
  112. package/packages/pi/extensions/service-skills.ts +6 -1
  113. package/packages/pi/extensions/session.ts +97 -15
  114. package/packages/pi/extensions/startup-briefing.ts +313 -0
  115. package/packages/pi/extensions/stratus-bridge.ts +2 -1
  116. package/packages/pi/extensions/subway-mesh.ts +893 -0
  117. package/packages/pi/extensions/synopsis-tool.ts +6 -1
  118. package/packages/pi/extensions/training-buffer-tool.ts +3 -2
  119. package/packages/pi/extensions/types.ts +3 -0
  120. package/packages/pi/package.json +4 -1
  121. package/packages/pi/skills/viz/SKILL.md +204 -0
  122. package/scripts/pp-branch-pr.sh +24 -6
  123. package/scripts/pp-branch-pr.sh.bak +115 -0
  124. package/template/.pi/settings.json +5 -0
  125. package/template/CLAUDE.md +82 -1738
  126. package/template/CLAUDE.md.bak +0 -1187
@@ -0,0 +1,893 @@
1
+ /**
2
+ * Subway Mesh Extension
3
+ *
4
+ * WebSocket client for the Subway P2P agent mesh. Provides real-time
5
+ * messaging, RPC calls, pub/sub broadcasting, and name resolution
6
+ * between Pi agent sessions connected to a Subway relay.
7
+ *
8
+ * Registers tools: subway_send, subway_call, subway_resolve,
9
+ * subway_rpc_respond, subway_inbox, subway_subscribe, subway_unsubscribe,
10
+ * subway_broadcast.
11
+ *
12
+ * Registers command: /subway (status, connect, disconnect, send, etc.)
13
+ *
14
+ * Provides TUI integration: status bar indicator, inbox widget,
15
+ * and agent context injection on each turn.
16
+ *
17
+ * @purpose Subway P2P mesh client — messaging, RPC, pub/sub tools + TUI
18
+ */
19
+
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs"
21
+ import { join } from "path"
22
+ import { homedir } from "os"
23
+ import type { PiContext, JflConfig, PiTheme } from "./types.js"
24
+
25
+ // ─── Types ───────────────────────────────────────────────────────────────────
26
+
27
+ export interface SubwayMessage {
28
+ ts: number
29
+ from: string
30
+ payload: string
31
+ }
32
+
33
+ export interface SubwayConfig {
34
+ name?: string
35
+ relay?: string
36
+ debug?: boolean
37
+ }
38
+
39
+ export interface CallResult {
40
+ success: boolean
41
+ payload: string
42
+ error: string
43
+ }
44
+
45
+ interface PendingRequest {
46
+ resolve: (value: unknown) => void
47
+ reject: (error: Error) => void
48
+ timer: ReturnType<typeof setTimeout>
49
+ }
50
+
51
+ type MessageHandler = (type: string, msg: Record<string, unknown>) => void
52
+
53
+ // ─── Constants ───────────────────────────────────────────────────────────────
54
+
55
+ const RPC_TIMEOUT_MS = 30_000
56
+ const RESOLVE_TIMEOUT_MS = 10_000
57
+ const KEEPALIVE_INTERVAL_MS = 15_000
58
+ const HEALTH_CHECK_INTERVAL_MS = 60_000
59
+ const MAX_INBOX = 50
60
+ const MAX_VISIBLE_WIDGET = 3
61
+
62
+ const DEFAULT_RELAY = "wss://relay.subway.dev/ws"
63
+ const DEFAULT_NAME = "claude.relay"
64
+ const CONFIG_DIR = join(homedir(), ".subway")
65
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json")
66
+ const DEBUG_LOG_PATH = join(CONFIG_DIR, "debug.log")
67
+
68
+ // ─── Config helpers ──────────────────────────────────────────────────────────
69
+
70
+ export function loadConfig(): SubwayConfig {
71
+ try {
72
+ if (existsSync(CONFIG_PATH)) {
73
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"))
74
+ }
75
+ } catch {}
76
+ return {}
77
+ }
78
+
79
+ export function saveConfig(cfg: SubwayConfig): void {
80
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
81
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n")
82
+ }
83
+
84
+ export function normalizeName(name: string): string {
85
+ return name.endsWith(".relay") ? name : `${name}.relay`
86
+ }
87
+
88
+ export function displayName(name: string): string {
89
+ return name.replace(/\.relay$/, "")
90
+ }
91
+
92
+ // ─── SubwayClient ────────────────────────────────────────────────────────────
93
+
94
+ export class SubwayClient {
95
+ name: string
96
+ relayUrl: string
97
+ registered = false
98
+ inbox: SubwayMessage[] = []
99
+
100
+ private ws: import("ws").WebSocket | null = null
101
+ private WebSocketCtor: typeof import("ws").WebSocket | null = null
102
+ private debug: boolean
103
+ private intentionalDisconnect = false
104
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
105
+ private keepaliveTimer: ReturnType<typeof setInterval> | null = null
106
+ private healthCheckTimer: ReturnType<typeof setInterval> | null = null
107
+ private registeredPeerId: string | null = null
108
+ private pending = new Map<string, PendingRequest>()
109
+ private _subscriptions = new Set<string>()
110
+ private onMessage: MessageHandler | null = null
111
+ private onStatusChange: (() => void) | null = null
112
+
113
+ get subscriptions(): ReadonlySet<string> {
114
+ return this._subscriptions
115
+ }
116
+
117
+ constructor(config: SubwayConfig) {
118
+ this.name = normalizeName(config.name || DEFAULT_NAME)
119
+ this.relayUrl = config.relay || DEFAULT_RELAY
120
+ this.debug = config.debug ?? false
121
+ }
122
+
123
+ onMessageReceived(handler: MessageHandler): void {
124
+ this.onMessage = handler
125
+ }
126
+
127
+ onStatus(handler: () => void): void {
128
+ this.onStatusChange = handler
129
+ }
130
+
131
+ private async ensureWs(): Promise<typeof import("ws").WebSocket> {
132
+ if (this.WebSocketCtor) return this.WebSocketCtor
133
+ const ws = await import("ws")
134
+ this.WebSocketCtor = ws.default ?? ws.WebSocket
135
+ return this.WebSocketCtor!
136
+ }
137
+
138
+ async connect(): Promise<void> {
139
+ const WS = await this.ensureWs()
140
+ if (this.ws && (this.ws.readyState === WS.OPEN || this.ws.readyState === WS.CONNECTING)) return
141
+ this.intentionalDisconnect = false
142
+
143
+ this.ws = new WS(this.relayUrl)
144
+
145
+ this.ws.on("open", () => {
146
+ this.wsSend({ type: "register", name: this.name })
147
+ if (this.keepaliveTimer) clearInterval(this.keepaliveTimer)
148
+ this.keepaliveTimer = setInterval(() => {
149
+ if (this.ws?.readyState === WS.OPEN) this.ws.ping()
150
+ }, KEEPALIVE_INTERVAL_MS)
151
+ })
152
+
153
+ this.ws.on("message", (data: Buffer) => {
154
+ let msg: Record<string, unknown>
155
+ try {
156
+ msg = JSON.parse(data.toString())
157
+ } catch {
158
+ return
159
+ }
160
+
161
+ if (this.debug) {
162
+ appendFileSync(DEBUG_LOG_PATH, `${new Date().toISOString()} ${data.toString().slice(0, 500)}\n`)
163
+ }
164
+
165
+ this.dispatch(msg)
166
+ })
167
+
168
+ this.ws.on("close", () => {
169
+ this.registered = false
170
+ this.onStatusChange?.()
171
+ if (!this.intentionalDisconnect) {
172
+ this.reconnectTimer = setTimeout(() => this.connect(), 5000)
173
+ }
174
+ })
175
+
176
+ this.ws.on("error", () => {})
177
+ }
178
+
179
+ disconnect(): void {
180
+ this.intentionalDisconnect = true
181
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
182
+ if (this.keepaliveTimer) { clearInterval(this.keepaliveTimer); this.keepaliveTimer = null }
183
+ if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null }
184
+ for (const [, p] of this.pending) {
185
+ clearTimeout(p.timer)
186
+ p.reject(new Error("disconnected"))
187
+ }
188
+ this.pending.clear()
189
+ if (this.ws) { this.ws.removeAllListeners(); this.ws.close(); this.ws = null }
190
+ this.registered = false
191
+ this.registeredPeerId = null
192
+ this._subscriptions.clear()
193
+ this.onStatusChange?.()
194
+ }
195
+
196
+ private startHealthCheck(): void {
197
+ if (this.healthCheckTimer) clearInterval(this.healthCheckTimer)
198
+ this.healthCheckTimer = setInterval(() => this.runHealthCheck(), HEALTH_CHECK_INTERVAL_MS)
199
+ }
200
+
201
+ private runHealthCheck(): void {
202
+ if (!this.registered || !this.ws) return
203
+ const WS = this.WebSocketCtor
204
+ if (!WS || this.ws.readyState !== WS.OPEN) return
205
+
206
+ const checkId = `healthcheck:${this.name}:${Date.now()}`
207
+ const timer = setTimeout(() => {
208
+ this.pending.delete(checkId)
209
+ this.log("health check timed out — forcing reconnect")
210
+ this.forceReconnect()
211
+ }, 10_000)
212
+
213
+ this.pending.set(checkId, {
214
+ resolve: (peerId: unknown) => {
215
+ if (this.registeredPeerId && peerId !== this.registeredPeerId) {
216
+ this.log(`stale registration detected: relay has ${peerId}, we are ${this.registeredPeerId} — forcing reconnect`)
217
+ this.forceReconnect()
218
+ }
219
+ },
220
+ reject: () => {
221
+ this.log("health check resolve failed — name not found, forcing reconnect")
222
+ this.forceReconnect()
223
+ },
224
+ timer,
225
+ })
226
+ this.wsSend({ type: "resolve", name: this.name })
227
+ }
228
+
229
+ private forceReconnect(): void {
230
+ if (this.ws) { this.ws.removeAllListeners(); this.ws.close(); this.ws = null }
231
+ this.registered = false
232
+ this.registeredPeerId = null
233
+ this.onStatusChange?.()
234
+ setTimeout(() => this.connect(), 1000)
235
+ }
236
+
237
+ private log(msg: string): void {
238
+ if (this.debug) {
239
+ appendFileSync(DEBUG_LOG_PATH, `${new Date().toISOString()} [health] ${msg}\n`)
240
+ }
241
+ }
242
+
243
+ async connectAndWait(timeoutMs = 5000): Promise<boolean> {
244
+ const WS = await this.ensureWs()
245
+ if (this.registered && this.ws?.readyState === WS.OPEN) return true
246
+ await this.connect()
247
+ return new Promise((resolve) => {
248
+ const check = setInterval(() => {
249
+ if (this.registered) {
250
+ clearInterval(check)
251
+ clearTimeout(timeout)
252
+ resolve(true)
253
+ }
254
+ }, 50)
255
+ const timeout = setTimeout(() => {
256
+ clearInterval(check)
257
+ resolve(false)
258
+ }, timeoutMs)
259
+ })
260
+ }
261
+
262
+ send(to: string, text: string): boolean {
263
+ if (!this.registered) return false
264
+ return this.wsSend({ type: "send", to, message_type: "text", payload: text })
265
+ }
266
+
267
+ call(to: string, method: string, payload?: string, signal?: AbortSignal): Promise<CallResult> {
268
+ return new Promise((resolve, reject) => {
269
+ if (signal?.aborted) return reject(new Error("aborted"))
270
+ if (!this.isConnected()) return reject(new Error("Not connected to Subway relay"))
271
+ const id = crypto.randomUUID()
272
+ const cleanup = () => { this.pending.delete(id); clearTimeout(timer) }
273
+ const timer = setTimeout(() => {
274
+ cleanup()
275
+ reject(new Error(`RPC call to ${to}.${method} timed out (${RPC_TIMEOUT_MS / 1000}s)`))
276
+ }, RPC_TIMEOUT_MS)
277
+ const onAbort = () => { cleanup(); reject(new Error("aborted")) }
278
+ signal?.addEventListener("abort", onAbort, { once: true })
279
+ this.pending.set(id, {
280
+ resolve: (v: unknown) => { signal?.removeEventListener("abort", onAbort); resolve(v as CallResult) },
281
+ reject: (e: Error) => { signal?.removeEventListener("abort", onAbort); reject(e) },
282
+ timer,
283
+ })
284
+ this.wsSend({ type: "call", to, method, payload: payload ?? "", correlation_id: id })
285
+ })
286
+ }
287
+
288
+ resolve(targetName: string, signal?: AbortSignal): Promise<string> {
289
+ return new Promise((resolve, reject) => {
290
+ if (signal?.aborted) return reject(new Error("aborted"))
291
+ if (!this.isConnected()) return reject(new Error("Not connected to Subway relay"))
292
+ const id = `resolve:${targetName}:${Date.now()}`
293
+ const cleanup = () => { this.pending.delete(id); clearTimeout(timer) }
294
+ const timer = setTimeout(() => {
295
+ cleanup()
296
+ reject(new Error(`Resolve timed out: ${targetName}`))
297
+ }, RESOLVE_TIMEOUT_MS)
298
+ const onAbort = () => { cleanup(); reject(new Error("aborted")) }
299
+ signal?.addEventListener("abort", onAbort, { once: true })
300
+ this.pending.set(id, {
301
+ resolve: (v: unknown) => { signal?.removeEventListener("abort", onAbort); resolve(v as string) },
302
+ reject: (e: Error) => { signal?.removeEventListener("abort", onAbort); reject(e) },
303
+ timer,
304
+ })
305
+ this.wsSend({ type: "resolve", name: targetName })
306
+ })
307
+ }
308
+
309
+ respondToRpc(correlationId: string, success: boolean, payload: string, error?: string): boolean {
310
+ if (!this.registered) return false
311
+ return this.wsSend({
312
+ type: "call_response",
313
+ correlation_id: correlationId,
314
+ success,
315
+ payload,
316
+ error: error ?? "",
317
+ })
318
+ }
319
+
320
+ subscribe(topic: string): boolean {
321
+ if (!this.registered) return false
322
+ const ok = this.wsSend({ type: "subscribe", topic })
323
+ if (ok) this._subscriptions.add(topic)
324
+ return ok
325
+ }
326
+
327
+ unsubscribe(topic: string): boolean {
328
+ if (!this.registered) return false
329
+ const ok = this.wsSend({ type: "unsubscribe", topic })
330
+ if (ok) this._subscriptions.delete(topic)
331
+ return ok
332
+ }
333
+
334
+ broadcast(topic: string, text: string): boolean {
335
+ if (!this.registered) return false
336
+ return this.wsSend({ type: "broadcast", topic, message_type: "text", payload: text })
337
+ }
338
+
339
+ setName(name: string): void {
340
+ this.name = normalizeName(name)
341
+ }
342
+
343
+ private isConnected(): boolean {
344
+ return !!(this.ws && this.registered)
345
+ }
346
+
347
+ private wsSend(msg: Record<string, unknown>): boolean {
348
+ const WS = this.WebSocketCtor
349
+ if (!this.ws || !WS || this.ws.readyState !== WS.OPEN) return false
350
+ this.ws.send(JSON.stringify(msg))
351
+ return true
352
+ }
353
+
354
+ private pushInbox(from: string, payload: string): SubwayMessage {
355
+ const entry: SubwayMessage = { ts: Date.now(), from, payload }
356
+ this.inbox.push(entry)
357
+ if (this.inbox.length > MAX_INBOX) this.inbox.splice(0, this.inbox.length - MAX_INBOX)
358
+ return entry
359
+ }
360
+
361
+ private dispatch(msg: Record<string, unknown>): void {
362
+ const type = msg.type as string
363
+
364
+ switch (type) {
365
+ case "registered":
366
+ this.registered = true
367
+ this.registeredPeerId = (msg.peer_id as string) ?? null
368
+ this.startHealthCheck()
369
+ this.onStatusChange?.()
370
+ break
371
+
372
+ case "message":
373
+ this.pushInbox(
374
+ (msg.from_name || msg.from_peer) as string,
375
+ msg.payload as string,
376
+ )
377
+ break
378
+
379
+ case "inbound_call":
380
+ this.pushInbox(
381
+ (msg.from_name || msg.from_peer || "unknown") as string,
382
+ `[RPC ${msg.method || "unknown"}] ${msg.payload || ""}`,
383
+ )
384
+ break
385
+
386
+ case "call_result": {
387
+ const cid = msg.correlation_id as string
388
+ const p = cid ? this.pending.get(cid) : undefined
389
+ if (p) {
390
+ this.pending.delete(cid)
391
+ clearTimeout(p.timer)
392
+ p.resolve({
393
+ success: msg.success ?? false,
394
+ payload: msg.payload ?? "",
395
+ error: msg.error ?? "",
396
+ })
397
+ }
398
+ break
399
+ }
400
+
401
+ case "resolved": {
402
+ const name = msg.name as string
403
+ const key = [...this.pending.keys()].find((k) =>
404
+ k.startsWith(`resolve:${name}:`) || k.startsWith(`healthcheck:${name}:`),
405
+ )
406
+ if (key) {
407
+ const p = this.pending.get(key)!
408
+ this.pending.delete(key)
409
+ clearTimeout(p.timer)
410
+ p.resolve(msg.peer_id ?? "")
411
+ }
412
+ break
413
+ }
414
+
415
+ case "broadcast_message":
416
+ this.pushInbox(
417
+ (msg.from_name || msg.from_peer_id || "unknown") as string,
418
+ `[${msg.topic}] ${msg.payload}`,
419
+ )
420
+ break
421
+
422
+ case "error": {
423
+ const errMsg = (msg.message || msg.error || "") as string
424
+ const errorName = msg.name as string | undefined
425
+ if (errMsg.includes("name_not_found") && errorName) {
426
+ const key = [...this.pending.keys()].find((k) =>
427
+ k.startsWith(`resolve:${errorName}:`) || k.startsWith(`healthcheck:${errorName}:`),
428
+ )
429
+ if (key) {
430
+ const p = this.pending.get(key)!
431
+ this.pending.delete(key)
432
+ clearTimeout(p.timer)
433
+ p.reject(new Error(`Name not found: ${errorName}`))
434
+ }
435
+ }
436
+ break
437
+ }
438
+ }
439
+
440
+ this.onMessage?.(type, msg)
441
+ }
442
+ }
443
+
444
+ // ─── Exported module state (for other JFL modules to query) ──────────────────
445
+
446
+ let meshClient: SubwayClient | null = null
447
+
448
+ export function getMeshClient(): SubwayClient | null {
449
+ return meshClient
450
+ }
451
+
452
+ // ─── Setup ───────────────────────────────────────────────────────────────────
453
+
454
+ let standaloneDetected = false
455
+
456
+ export function isStandaloneSubwayLoaded(): boolean {
457
+ return standaloneDetected
458
+ }
459
+
460
+ export async function setupSubwayMesh(ctx: PiContext, _config: JflConfig): Promise<void> {
461
+ // Detect if the standalone subway Pi extension is already loaded.
462
+ // If so, skip everything — let the standalone handle mesh tools, TUI, and lifecycle.
463
+ // This avoids double WebSocket connections, duplicate tool registrations, and command conflicts.
464
+ const existingTools = ctx.pi.getAllTools()
465
+ if (existingTools.some(t => t.name === "subway_send")) {
466
+ standaloneDetected = true
467
+ ctx.log("Subway mesh: standalone extension detected, skipping built-in mesh setup", "debug")
468
+ return
469
+ }
470
+
471
+ const config = loadConfig()
472
+ const client = new SubwayClient(config)
473
+ meshClient = client
474
+
475
+ let widgetDebounce: ReturnType<typeof setTimeout> | null = null
476
+
477
+ function scheduleWidgetUpdate(): void {
478
+ if (widgetDebounce) clearTimeout(widgetDebounce)
479
+ widgetDebounce = setTimeout(() => updateWidget(), 250)
480
+ }
481
+
482
+ // ─── Status bar ────────────────────────────────────────────────────────────
483
+
484
+ client.onStatus(() => {
485
+ if (client.registered) {
486
+ ctx.ui.setStatus("subway", ctx.ui.theme.fg("success", `● ${client.name}`))
487
+ ctx.ui.notify(`Subway: connected as ${client.name}`, { level: "info" })
488
+ } else {
489
+ ctx.ui.setStatus("subway", ctx.ui.theme.fg("error", "○ subway (disconnected)"))
490
+ }
491
+ updateWidget()
492
+ })
493
+
494
+ // ─── Message delivery into conversation ────────────────────────────────────
495
+
496
+ client.onMessageReceived((type, msg) => {
497
+ if (type === "message") {
498
+ const from = (msg.from_name || msg.from_peer) as string
499
+ ctx.pi.sendMessage(
500
+ { customType: `subway - ${displayName(from)}`, content: msg.payload as string, display: true },
501
+ { triggerTurn: true, deliverAs: "followUp" },
502
+ )
503
+ scheduleWidgetUpdate()
504
+ }
505
+
506
+ if (type === "inbound_call") {
507
+ const from = (msg.from_name || msg.from_peer || "unknown") as string
508
+ const method = msg.method || "unknown"
509
+ const correlationId = msg.correlation_id || ""
510
+ ctx.pi.sendMessage(
511
+ {
512
+ customType: `subway - ${displayName(from)}`,
513
+ content: `[RPC ${method}] correlation_id=${correlationId}\n\n${msg.payload || ""}`,
514
+ display: true,
515
+ },
516
+ { triggerTurn: true, deliverAs: "followUp" },
517
+ )
518
+ scheduleWidgetUpdate()
519
+ }
520
+
521
+ if (type === "broadcast_message") {
522
+ const from = (msg.from_name || msg.from_peer_id || "unknown") as string
523
+ ctx.pi.sendMessage(
524
+ { customType: `subway - ${displayName(from)}`, content: `[${msg.topic}] ${msg.payload}`, display: true },
525
+ { triggerTurn: false },
526
+ )
527
+ scheduleWidgetUpdate()
528
+ }
529
+
530
+ if (type === "error") {
531
+ const errMsg = (msg.message || msg.error || "") as string
532
+ if (errMsg.includes("NoPeersSubscribedToTopic")) {
533
+ ctx.ui.notify("Subway: no subscribers on topic", { level: "warn" })
534
+ } else if (!errMsg.includes("already registered")) {
535
+ ctx.ui.notify(`Subway error: ${errMsg}`, { level: "error" })
536
+ }
537
+ }
538
+ })
539
+
540
+ // ─── Widget ────────────────────────────────────────────────────────────────
541
+
542
+ function updateWidget(): void {
543
+ if (!client.registered) {
544
+ ctx.ui.setWidget("subway", undefined)
545
+ ctx.ui.setStatus("subway", undefined)
546
+ return
547
+ }
548
+
549
+ ctx.ui.setWidget("subway", (_tui: any, theme: PiTheme) => ({
550
+ render: (width: number) => {
551
+ const lines: string[] = []
552
+ const relay = client.relayUrl.replace("wss://", "").replace("/ws", "")
553
+ lines.push(`${theme.fg("success", "●")} ${theme.fg("accent", client.name)} ${theme.fg("dim", `→ ${relay}`)} ${theme.fg("dim", "(connected)")}`)
554
+
555
+ const recent = client.inbox.slice(-MAX_VISIBLE_WIDGET)
556
+ const maxPayload = Math.max(20, width - 30)
557
+ for (const m of recent) {
558
+ const time = new Date(m.ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })
559
+ const payload = m.payload.length > maxPayload ? m.payload.slice(0, maxPayload - 3) + "..." : m.payload
560
+ const line = theme.fg("dim", time) + " " + theme.fg("accent", m.from) + " " + payload
561
+ lines.push(line.length > width ? line.slice(0, width - 1) + "…" : line)
562
+ }
563
+
564
+ return lines
565
+ },
566
+ invalidate: () => {},
567
+ }))
568
+ }
569
+
570
+ // ─── Tools ─────────────────────────────────────────────────────────────────
571
+
572
+ ctx.registerTool({
573
+ name: "subway_send",
574
+ description: "Send a message to another agent on the Subway P2P mesh. Use this to reply to subway messages or reach out to peers.",
575
+ promptSnippet: "Send a message to another agent on the Subway P2P mesh",
576
+ promptGuidelines: [
577
+ "When you receive a [Subway message from X], reply using subway_send with to=X.",
578
+ "Agent names end in .relay (e.g., datboi.relay, hathbanger.relay, andrew.relay).",
579
+ ],
580
+ inputSchema: {
581
+ type: "object",
582
+ properties: {
583
+ to: { type: "string", description: "Target agent name (e.g. datboi.relay)" },
584
+ message: { type: "string", description: "Message text to send" },
585
+ },
586
+ required: ["to", "message"],
587
+ },
588
+ async handler(input) {
589
+ const { to, message } = input as { to: string; message: string }
590
+ if (!client.send(to, message)) return "Not connected to Subway relay."
591
+ return `Sent to ${to}: ${message}`
592
+ },
593
+ })
594
+
595
+ ctx.registerTool({
596
+ name: "subway_call",
597
+ description:
598
+ "Make an RPC call to another agent on the Subway P2P mesh and wait for a response. " +
599
+ "Use this when you need a result back — e.g. asking an agent to run a task and report the outcome. Timeout: 30 seconds.",
600
+ promptSnippet: "Make an RPC call to another Subway agent and wait for response (30s timeout)",
601
+ promptGuidelines: [
602
+ "Use subway_call when you need a response back from an agent.",
603
+ "Use subway_send for fire-and-forget messages that don't need a reply.",
604
+ ],
605
+ inputSchema: {
606
+ type: "object",
607
+ properties: {
608
+ to: { type: "string", description: "Target agent name (e.g. datboi.relay)" },
609
+ method: { type: "string", description: "RPC method name (e.g. 'ping', 'ask', 'execute', 'status')" },
610
+ payload: { type: "string", description: "Request payload (text)" },
611
+ },
612
+ required: ["to", "method"],
613
+ },
614
+ async handler(input) {
615
+ const { to, method, payload } = input as { to: string; method: string; payload?: string }
616
+ try {
617
+ const result = await client.call(to, method, payload)
618
+ return result.success
619
+ ? result.payload || "(empty response)"
620
+ : `RPC to ${to}.${method} failed: ${result.error}`
621
+ } catch (err) {
622
+ return `RPC call failed: ${(err as Error).message}`
623
+ }
624
+ },
625
+ })
626
+
627
+ ctx.registerTool({
628
+ name: "subway_resolve",
629
+ description: "Check if an agent is online on the Subway P2P mesh by resolving its name. Returns the agent's PeerId if found.",
630
+ promptSnippet: "Check if a Subway agent is online by name",
631
+ promptGuidelines: [
632
+ "Use subway_resolve to check if an agent is reachable before calling or sending.",
633
+ ],
634
+ inputSchema: {
635
+ type: "object",
636
+ properties: {
637
+ name: { type: "string", description: "Agent name to look up (e.g. datboi.relay)" },
638
+ },
639
+ required: ["name"],
640
+ },
641
+ async handler(input) {
642
+ const { name } = input as { name: string }
643
+ try {
644
+ const peerId = await client.resolve(name)
645
+ return `${name} is online (peer: ${peerId})`
646
+ } catch (err) {
647
+ return `${name} is not reachable: ${(err as Error).message}`
648
+ }
649
+ },
650
+ })
651
+
652
+ ctx.registerTool({
653
+ name: "subway_rpc_respond",
654
+ description:
655
+ "Respond to an inbound RPC call from another agent. Use this after receiving a [Subway RPC call] message. " +
656
+ "You must include the correlation_id from the inbound call.",
657
+ promptSnippet: "Respond to an inbound Subway RPC call with correlation_id",
658
+ promptGuidelines: [
659
+ "When you receive a [Subway RPC call from X] with a correlation_id, process the request and respond using subway_rpc_respond.",
660
+ "Include the correlation_id exactly as received.",
661
+ ],
662
+ inputSchema: {
663
+ type: "object",
664
+ properties: {
665
+ correlation_id: { type: "string", description: "The correlation_id from the inbound RPC call" },
666
+ success: { type: "boolean", description: "Whether the call succeeded" },
667
+ payload: { type: "string", description: "Response payload text" },
668
+ error: { type: "string", description: "Error message if success is false" },
669
+ },
670
+ required: ["correlation_id", "success", "payload"],
671
+ },
672
+ async handler(input) {
673
+ const { correlation_id, success, payload, error } = input as {
674
+ correlation_id: string; success: boolean; payload: string; error?: string
675
+ }
676
+ if (!client.respondToRpc(correlation_id, success, payload, error)) return "Not connected to Subway relay."
677
+ return `RPC response sent (success=${success})`
678
+ },
679
+ })
680
+
681
+ ctx.registerTool({
682
+ name: "subway_inbox",
683
+ description: "Check recent messages received on the Subway mesh.",
684
+ promptSnippet: "Check recent Subway messages",
685
+ promptGuidelines: [
686
+ "Use subway_inbox to review recent messages if context was lost or to catch up after reconnecting.",
687
+ ],
688
+ inputSchema: {
689
+ type: "object",
690
+ properties: {
691
+ count: { type: "number", description: "Number of recent messages (default 10)" },
692
+ },
693
+ },
694
+ async handler(input) {
695
+ const { count } = input as { count?: number }
696
+ const recent = client.inbox.slice(-(count || 10))
697
+ if (recent.length === 0) return "No messages in inbox."
698
+ return recent.map((m) => `[${new Date(m.ts).toISOString()}] ${m.from}: ${m.payload}`).join("\n")
699
+ },
700
+ })
701
+
702
+ ctx.registerTool({
703
+ name: "subway_subscribe",
704
+ description:
705
+ "Subscribe to a pub/sub topic on the Subway mesh. Wildcard supported: 'metrics.*' matches metrics.cpu, metrics.mem. " +
706
+ "Broadcast messages on subscribed topics will appear in the conversation.",
707
+ promptSnippet: "Subscribe to a Subway pub/sub topic (wildcards supported)",
708
+ promptGuidelines: [
709
+ "Use subway_subscribe to listen for broadcast messages on a topic.",
710
+ "Wildcard topics like 'metrics.*' match all subtopics (e.g. metrics.cpu, metrics.mem).",
711
+ "Subscriptions persist for the session — use subway_unsubscribe to stop receiving a topic.",
712
+ ],
713
+ inputSchema: {
714
+ type: "object",
715
+ properties: {
716
+ topic: { type: "string", description: "Topic to subscribe to (e.g. 'status', 'metrics.*')" },
717
+ },
718
+ required: ["topic"],
719
+ },
720
+ async handler(input) {
721
+ const { topic } = input as { topic: string }
722
+ if (!client.subscribe(topic)) return "Not connected to Subway relay."
723
+ return `Subscribed to topic: ${topic}`
724
+ },
725
+ })
726
+
727
+ ctx.registerTool({
728
+ name: "subway_unsubscribe",
729
+ description: "Unsubscribe from a pub/sub topic on the Subway mesh.",
730
+ promptSnippet: "Unsubscribe from a Subway pub/sub topic",
731
+ promptGuidelines: [
732
+ "Use subway_unsubscribe when you no longer need to receive messages on a topic.",
733
+ ],
734
+ inputSchema: {
735
+ type: "object",
736
+ properties: {
737
+ topic: { type: "string", description: "Topic to unsubscribe from" },
738
+ },
739
+ required: ["topic"],
740
+ },
741
+ async handler(input) {
742
+ const { topic } = input as { topic: string }
743
+ if (!client.unsubscribe(topic)) return "Not connected to Subway relay."
744
+ return `Unsubscribed from topic: ${topic}`
745
+ },
746
+ })
747
+
748
+ ctx.registerTool({
749
+ name: "subway_broadcast",
750
+ description:
751
+ "Broadcast a message to all subscribers of a topic on the Subway P2P mesh. " +
752
+ "All agents subscribed to the topic (or a matching wildcard) will receive the message.",
753
+ promptSnippet: "Broadcast a message to all subscribers of a Subway topic",
754
+ promptGuidelines: [
755
+ "Use subway_broadcast to publish to all agents subscribed to a topic.",
756
+ "Any agent subscribed to the topic or a matching wildcard will receive the message.",
757
+ "Use subway_send for direct messages to a specific agent instead.",
758
+ ],
759
+ inputSchema: {
760
+ type: "object",
761
+ properties: {
762
+ topic: { type: "string", description: "Topic to broadcast to (e.g. 'status', 'builds')" },
763
+ message: { type: "string", description: "Message text to broadcast" },
764
+ },
765
+ required: ["topic", "message"],
766
+ },
767
+ async handler(input) {
768
+ const { topic, message } = input as { topic: string; message: string }
769
+ if (!client.broadcast(topic, message)) return "Not connected to Subway relay."
770
+ return `Broadcast to ${topic}: ${message}`
771
+ },
772
+ })
773
+
774
+ // ─── /subway command ────────────────────────────────────────────────────────
775
+
776
+ ctx.registerCommand({
777
+ name: "subway",
778
+ description: "Subway mesh commands (status, connect [name], disconnect, reconnect [name], send <to> <msg>, subscribe <topic>, unsubscribe <topic>, broadcast <topic> <msg>, inbox)",
779
+ async handler(args: string, _ctx: PiContext) {
780
+ const parts = args.trim().split(/\s+/)
781
+ const sub = parts[0] || "status"
782
+
783
+ switch (sub) {
784
+ case "status": {
785
+ const status = client.registered ? "connected" : "disconnected"
786
+ ctx.ui.notify(
787
+ `Subway: ${status} as ${client.name} | relay: ${client.relayUrl} | ${client.inbox.length} messages`,
788
+ { level: client.registered ? "info" : "warn" },
789
+ )
790
+ break
791
+ }
792
+ case "connect": {
793
+ if (parts[1]) {
794
+ const newName = normalizeName(parts[1])
795
+ client.setName(newName)
796
+ const cfg = loadConfig()
797
+ cfg.name = newName
798
+ saveConfig(cfg)
799
+ if (client.registered) client.disconnect()
800
+ } else if (client.registered) {
801
+ ctx.ui.notify(`Already connected as ${client.name}`, { level: "info" })
802
+ break
803
+ }
804
+ ctx.ui.notify(`Connecting as ${client.name}...`, { level: "info" })
805
+ const ok = await client.connectAndWait()
806
+ ctx.ui.notify(ok ? `Connected as ${client.name}` : "Failed to connect (timeout)", { level: ok ? "info" : "error" })
807
+ break
808
+ }
809
+ case "disconnect": {
810
+ if (!client.registered) { ctx.ui.notify("Already disconnected", { level: "info" }); break }
811
+ client.disconnect()
812
+ ctx.ui.notify("Disconnected from Subway", { level: "info" })
813
+ break
814
+ }
815
+ case "reconnect": {
816
+ if (parts[1]) {
817
+ const newName = normalizeName(parts[1])
818
+ client.setName(newName)
819
+ const cfg = loadConfig()
820
+ cfg.name = newName
821
+ saveConfig(cfg)
822
+ }
823
+ ctx.ui.notify(`Reconnecting as ${client.name}...`, { level: "info" })
824
+ client.disconnect()
825
+ const ok = await client.connectAndWait()
826
+ ctx.ui.notify(ok ? `Connected as ${client.name}` : "Failed to reconnect (timeout)", { level: ok ? "info" : "error" })
827
+ break
828
+ }
829
+ case "send": {
830
+ if (parts.length < 3) { ctx.ui.notify("Usage: /subway send <to> <msg>", { level: "info" }); break }
831
+ const ok = client.send(parts[1]!, parts.slice(2).join(" "))
832
+ ctx.ui.notify(ok ? `Sent to ${parts[1]}` : "Not connected", { level: ok ? "info" : "error" })
833
+ break
834
+ }
835
+ case "inbox": {
836
+ const recent = client.inbox.slice(-5)
837
+ if (recent.length === 0) { ctx.ui.notify("Inbox empty", { level: "info" }); break }
838
+ for (const m of recent) ctx.ui.notify(`${m.from}: ${m.payload}`, { level: "info" })
839
+ break
840
+ }
841
+ case "subscribe": {
842
+ if (!parts[1]) { ctx.ui.notify("Usage: /subway subscribe <topic>", { level: "info" }); break }
843
+ const ok = client.subscribe(parts[1])
844
+ ctx.ui.notify(ok ? `Subscribed to ${parts[1]}` : "Not connected", { level: ok ? "info" : "error" })
845
+ break
846
+ }
847
+ case "unsubscribe": {
848
+ if (!parts[1]) { ctx.ui.notify("Usage: /subway unsubscribe <topic>", { level: "info" }); break }
849
+ const ok = client.unsubscribe(parts[1])
850
+ ctx.ui.notify(ok ? `Unsubscribed from ${parts[1]}` : "Not connected", { level: ok ? "info" : "error" })
851
+ break
852
+ }
853
+ case "broadcast": {
854
+ if (parts.length < 3) { ctx.ui.notify("Usage: /subway broadcast <topic> <msg>", { level: "info" }); break }
855
+ const ok = client.broadcast(parts[1]!, parts.slice(2).join(" "))
856
+ ctx.ui.notify(ok ? `Broadcast to ${parts[1]}` : "Not connected", { level: ok ? "info" : "error" })
857
+ break
858
+ }
859
+ default:
860
+ ctx.ui.notify(
861
+ "Usage: /subway [status|connect [name]|disconnect|reconnect [name]|send <to> <msg>|subscribe <topic>|unsubscribe <topic>|broadcast <topic> <msg>|inbox]",
862
+ { level: "info" },
863
+ )
864
+ }
865
+ },
866
+ })
867
+
868
+ // ─── Auto-connect ──────────────────────────────────────────────────────────
869
+
870
+ updateWidget()
871
+
872
+ if (config.name) {
873
+ client.connectAndWait().catch(() => {})
874
+ }
875
+ }
876
+
877
+ // ─── Lifecycle hooks (called from index.ts) ──────────────────────────────────
878
+
879
+ export function injectMeshContext(): string | undefined {
880
+ if (standaloneDetected) return undefined
881
+ if (!meshClient?.registered) return undefined
882
+
883
+ const subs = [...meshClient.subscriptions]
884
+ const subsStr = subs.length > 0 ? ` | subscriptions: ${subs.join(", ")}` : ""
885
+ const relay = meshClient.relayUrl.replace("wss://", "").replace("/ws", "")
886
+ return `[Subway mesh: connected as ${meshClient.name} → ${relay}${subsStr} | ${meshClient.inbox.length} messages]`
887
+ }
888
+
889
+ export function onMeshShutdown(): void {
890
+ if (standaloneDetected) return
891
+ meshClient?.disconnect()
892
+ meshClient = null
893
+ }