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