@openlap/openlap 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel.d.ts +53 -0
- package/dist/channel.js +256 -0
- package/dist/index.js +39 -25
- package/dist/proxy.js +150 -29
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
interface ChannelUpdate {
|
|
2
|
+
id: string;
|
|
3
|
+
session_id?: string;
|
|
4
|
+
project_name: string;
|
|
5
|
+
display_name?: string;
|
|
6
|
+
body: string;
|
|
7
|
+
health: string;
|
|
8
|
+
created_at: string;
|
|
9
|
+
}
|
|
10
|
+
interface PresenceEvent {
|
|
11
|
+
action: "join" | "leave" | "timeout";
|
|
12
|
+
display_name: string;
|
|
13
|
+
channel: string;
|
|
14
|
+
}
|
|
15
|
+
interface JoinResponse {
|
|
16
|
+
channel: string;
|
|
17
|
+
present: string[];
|
|
18
|
+
recent: Array<{
|
|
19
|
+
id: string;
|
|
20
|
+
project_name?: string;
|
|
21
|
+
body: string;
|
|
22
|
+
health: string;
|
|
23
|
+
created_at: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
type UpdateCallback = (channel: string, update: ChannelUpdate) => void;
|
|
27
|
+
type PresenceCallback = (channel: string, event: PresenceEvent) => void;
|
|
28
|
+
export declare class ChannelManager {
|
|
29
|
+
private baseUrl;
|
|
30
|
+
private channels;
|
|
31
|
+
private seenIds;
|
|
32
|
+
private onUpdate;
|
|
33
|
+
private onPresence;
|
|
34
|
+
private getToken;
|
|
35
|
+
private mySessionId;
|
|
36
|
+
private heartbeatInterval;
|
|
37
|
+
constructor(baseUrl: string, onUpdate: UpdateCallback, getToken: () => string | undefined, onPresence?: PresenceCallback);
|
|
38
|
+
setSessionId(sessionId: string): void;
|
|
39
|
+
join(channel: string, name?: string): Promise<JoinResponse | null>;
|
|
40
|
+
leave(channel: string): Promise<void>;
|
|
41
|
+
mute(channel: string): boolean;
|
|
42
|
+
focus(channel: string): boolean;
|
|
43
|
+
getFocusedChannel(): string | undefined;
|
|
44
|
+
isJoined(channel: string): boolean;
|
|
45
|
+
isMuted(channel: string): boolean;
|
|
46
|
+
private connectSSE;
|
|
47
|
+
private readStream;
|
|
48
|
+
private handleSSEEvent;
|
|
49
|
+
private startHeartbeat;
|
|
50
|
+
private stopHeartbeat;
|
|
51
|
+
stop(): void;
|
|
52
|
+
}
|
|
53
|
+
export {};
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// v2: ChannelManager replaces FeedManager.
|
|
2
|
+
// Adds: join/leave via server API, mute/focus as local state,
|
|
3
|
+
// heartbeat loop, echo suppression by session_id.
|
|
4
|
+
export class ChannelManager {
|
|
5
|
+
baseUrl;
|
|
6
|
+
channels = new Map();
|
|
7
|
+
seenIds = new Set();
|
|
8
|
+
onUpdate;
|
|
9
|
+
onPresence;
|
|
10
|
+
getToken;
|
|
11
|
+
mySessionId;
|
|
12
|
+
heartbeatInterval = null;
|
|
13
|
+
constructor(baseUrl, onUpdate, getToken, onPresence) {
|
|
14
|
+
this.baseUrl = baseUrl;
|
|
15
|
+
this.onUpdate = onUpdate;
|
|
16
|
+
this.onPresence = onPresence;
|
|
17
|
+
this.getToken = getToken;
|
|
18
|
+
}
|
|
19
|
+
setSessionId(sessionId) {
|
|
20
|
+
this.mySessionId = sessionId;
|
|
21
|
+
}
|
|
22
|
+
// join calls the server API to register presence, then opens SSE.
|
|
23
|
+
async join(channel, name) {
|
|
24
|
+
if (this.channels.has(channel))
|
|
25
|
+
return null;
|
|
26
|
+
const token = this.getToken();
|
|
27
|
+
const headers = { "Content-Type": "application/json" };
|
|
28
|
+
if (token)
|
|
29
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${this.baseUrl}/api/channels/${encodeURIComponent(channel)}/join`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers,
|
|
34
|
+
body: JSON.stringify({ name: name ?? undefined }),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
process.stderr.write(`[openlap] join ${channel} failed: ${res.status}\n`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const data = (await res.json());
|
|
41
|
+
process.stderr.write(`[openlap] joined ${channel} (${data.present?.length ?? 0} present)\n`);
|
|
42
|
+
// Start SSE subscription
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
this.channels.set(channel, { controller, muted: false, focused: false });
|
|
45
|
+
this.connectSSE(channel, controller);
|
|
46
|
+
// Start heartbeat if first channel
|
|
47
|
+
if (this.channels.size === 1) {
|
|
48
|
+
this.startHeartbeat();
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
process.stderr.write(`[openlap] join ${channel} error: ${err}\n`);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// leave calls the server API to remove presence, then closes SSE.
|
|
58
|
+
async leave(channel) {
|
|
59
|
+
const state = this.channels.get(channel);
|
|
60
|
+
if (!state)
|
|
61
|
+
return;
|
|
62
|
+
state.controller.abort();
|
|
63
|
+
this.channels.delete(channel);
|
|
64
|
+
// Stop heartbeat if no channels
|
|
65
|
+
if (this.channels.size === 0) {
|
|
66
|
+
this.stopHeartbeat();
|
|
67
|
+
}
|
|
68
|
+
const token = this.getToken();
|
|
69
|
+
const headers = {};
|
|
70
|
+
if (token)
|
|
71
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
72
|
+
try {
|
|
73
|
+
await fetch(`${this.baseUrl}/api/channels/${encodeURIComponent(channel)}/leave`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers,
|
|
76
|
+
});
|
|
77
|
+
process.stderr.write(`[openlap] left ${channel}\n`);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// best effort
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// mute stops forwarding notifications but stays present (heartbeat continues).
|
|
84
|
+
mute(channel) {
|
|
85
|
+
const state = this.channels.get(channel);
|
|
86
|
+
if (!state)
|
|
87
|
+
return false;
|
|
88
|
+
state.muted = true;
|
|
89
|
+
process.stderr.write(`[openlap] muted ${channel}\n`);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
// focus unmutes a channel and sets it as the focused channel for briefings.
|
|
93
|
+
focus(channel) {
|
|
94
|
+
const state = this.channels.get(channel);
|
|
95
|
+
if (!state)
|
|
96
|
+
return false;
|
|
97
|
+
// Unmute the focused channel
|
|
98
|
+
state.focused = true;
|
|
99
|
+
state.muted = false;
|
|
100
|
+
// Unfocus all others (only one focused at a time)
|
|
101
|
+
for (const [name, s] of this.channels) {
|
|
102
|
+
if (name !== channel)
|
|
103
|
+
s.focused = false;
|
|
104
|
+
}
|
|
105
|
+
process.stderr.write(`[openlap] focused ${channel}\n`);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
getFocusedChannel() {
|
|
109
|
+
for (const [name, state] of this.channels) {
|
|
110
|
+
if (state.focused)
|
|
111
|
+
return name;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
isJoined(channel) {
|
|
116
|
+
return this.channels.has(channel);
|
|
117
|
+
}
|
|
118
|
+
isMuted(channel) {
|
|
119
|
+
return this.channels.get(channel)?.muted ?? false;
|
|
120
|
+
}
|
|
121
|
+
// SSE connection for a channel
|
|
122
|
+
connectSSE(channel, controller) {
|
|
123
|
+
const url = `${this.baseUrl}/feed/${channel}/sse`;
|
|
124
|
+
const headers = {};
|
|
125
|
+
const token = this.getToken();
|
|
126
|
+
if (token)
|
|
127
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
128
|
+
fetch(url, { headers, signal: controller.signal })
|
|
129
|
+
.then((res) => {
|
|
130
|
+
if (!res.ok || !res.body)
|
|
131
|
+
throw new Error(`SSE connect failed: ${res.status}`);
|
|
132
|
+
return this.readStream(channel, res.body, controller);
|
|
133
|
+
})
|
|
134
|
+
.catch((err) => {
|
|
135
|
+
if (err.name === "AbortError")
|
|
136
|
+
return;
|
|
137
|
+
if (this.channels.has(channel)) {
|
|
138
|
+
setTimeout(() => this.connectSSE(channel, controller), 5000);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async readStream(channel, body, controller) {
|
|
143
|
+
const reader = body.getReader();
|
|
144
|
+
const decoder = new TextDecoder();
|
|
145
|
+
let buffer = "";
|
|
146
|
+
let eventType = "";
|
|
147
|
+
let eventData = "";
|
|
148
|
+
let eventId = "";
|
|
149
|
+
try {
|
|
150
|
+
while (true) {
|
|
151
|
+
const { done, value } = await reader.read();
|
|
152
|
+
if (done)
|
|
153
|
+
break;
|
|
154
|
+
buffer += decoder.decode(value, { stream: true });
|
|
155
|
+
const lines = buffer.split("\n");
|
|
156
|
+
buffer = lines.pop() ?? "";
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
if (line.startsWith("event: ")) {
|
|
159
|
+
eventType = line.slice(7);
|
|
160
|
+
}
|
|
161
|
+
else if (line.startsWith("data: ")) {
|
|
162
|
+
eventData = line.slice(6);
|
|
163
|
+
}
|
|
164
|
+
else if (line.startsWith("id: ")) {
|
|
165
|
+
eventId = line.slice(4);
|
|
166
|
+
}
|
|
167
|
+
else if (line === "") {
|
|
168
|
+
this.handleSSEEvent(channel, eventType, eventData, eventId);
|
|
169
|
+
eventType = "";
|
|
170
|
+
eventData = "";
|
|
171
|
+
eventId = "";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// stream ended
|
|
178
|
+
}
|
|
179
|
+
// Reconnect if still joined
|
|
180
|
+
if (this.channels.has(channel) && !controller.signal.aborted) {
|
|
181
|
+
setTimeout(() => this.connectSSE(channel, controller), 5000);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
handleSSEEvent(channel, eventType, data, id) {
|
|
185
|
+
if (eventType === "update" && data && id) {
|
|
186
|
+
if (this.seenIds.has(id))
|
|
187
|
+
return;
|
|
188
|
+
this.seenIds.add(id);
|
|
189
|
+
// Cap seen set
|
|
190
|
+
if (this.seenIds.size > 500) {
|
|
191
|
+
const iter = this.seenIds.values();
|
|
192
|
+
for (let i = 0; i < 100; i++)
|
|
193
|
+
iter.next();
|
|
194
|
+
const toKeep = new Set();
|
|
195
|
+
for (const v of iter)
|
|
196
|
+
toKeep.add(v);
|
|
197
|
+
this.seenIds = toKeep;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const update = JSON.parse(data);
|
|
201
|
+
// Echo suppression: skip own posts
|
|
202
|
+
if (this.mySessionId && update.session_id === this.mySessionId)
|
|
203
|
+
return;
|
|
204
|
+
// Mute check: skip if channel is muted
|
|
205
|
+
if (this.channels.get(channel)?.muted)
|
|
206
|
+
return;
|
|
207
|
+
this.onUpdate(channel, update);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// skip malformed
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (eventType === "presence" && data) {
|
|
214
|
+
try {
|
|
215
|
+
const event = JSON.parse(data);
|
|
216
|
+
event.channel = channel;
|
|
217
|
+
this.onPresence?.(channel, event);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// skip malformed
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Heartbeat: POST /api/presence/heartbeat every 30s
|
|
225
|
+
startHeartbeat() {
|
|
226
|
+
if (this.heartbeatInterval)
|
|
227
|
+
return;
|
|
228
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
229
|
+
const token = this.getToken();
|
|
230
|
+
if (!token)
|
|
231
|
+
return;
|
|
232
|
+
try {
|
|
233
|
+
await fetch(`${this.baseUrl}/api/presence/heartbeat`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "Authorization": `Bearer ${token}` },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// heartbeat failure is not critical
|
|
240
|
+
}
|
|
241
|
+
}, 30_000);
|
|
242
|
+
}
|
|
243
|
+
stopHeartbeat() {
|
|
244
|
+
if (this.heartbeatInterval) {
|
|
245
|
+
clearInterval(this.heartbeatInterval);
|
|
246
|
+
this.heartbeatInterval = null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
stop() {
|
|
250
|
+
this.stopHeartbeat();
|
|
251
|
+
for (const [, state] of this.channels) {
|
|
252
|
+
state.controller.abort();
|
|
253
|
+
}
|
|
254
|
+
this.channels.clear();
|
|
255
|
+
}
|
|
256
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -6,44 +6,37 @@ if (command === "setup") {
|
|
|
6
6
|
console.log("Setting up openlap...");
|
|
7
7
|
console.log("");
|
|
8
8
|
try {
|
|
9
|
-
// Add
|
|
10
|
-
let added = false;
|
|
9
|
+
// Add MCP server entry
|
|
11
10
|
try {
|
|
12
|
-
execSync(`claude mcp add -s user openlap --
|
|
13
|
-
added = true;
|
|
11
|
+
execSync(`claude mcp add -s user openlap -- openlap`, { stdio: "pipe" });
|
|
14
12
|
console.log("Added openlap MCP server.");
|
|
15
13
|
}
|
|
16
14
|
catch (err) {
|
|
17
15
|
const msg = err instanceof Error ? err.stderr?.toString() ?? err.message : String(err);
|
|
18
16
|
if (msg.includes("already exists")) {
|
|
19
|
-
console.log("openlap MCP server already
|
|
20
|
-
added = true;
|
|
17
|
+
console.log("openlap MCP server already configured.");
|
|
21
18
|
}
|
|
22
19
|
else {
|
|
23
20
|
throw err;
|
|
24
21
|
}
|
|
25
22
|
}
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
// Remove old "lap" HTTP entry if present (migration from direct HTTP setup)
|
|
37
|
-
try {
|
|
38
|
-
execSync("claude mcp remove lap", { stdio: "pipe" });
|
|
39
|
-
console.log("Migrated: removed old 'lap' HTTP entry.");
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
// Not present, fine
|
|
43
|
-
}
|
|
23
|
+
// Remove old entries
|
|
24
|
+
try {
|
|
25
|
+
execSync("claude mcp remove anylap", { stdio: "pipe" });
|
|
26
|
+
console.log("Migrated: removed old 'anylap' entry.");
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
try {
|
|
30
|
+
execSync("claude mcp remove lap", { stdio: "pipe" });
|
|
31
|
+
console.log("Migrated: removed old 'lap' HTTP entry.");
|
|
44
32
|
}
|
|
33
|
+
catch { }
|
|
34
|
+
console.log("");
|
|
35
|
+
console.log("Done. Start Claude Code with live feeds:");
|
|
36
|
+
console.log("");
|
|
37
|
+
console.log(" claude --dangerously-load-development-channels");
|
|
45
38
|
console.log("");
|
|
46
|
-
console.log("
|
|
39
|
+
console.log("First session triggers GitHub login in browser.");
|
|
47
40
|
}
|
|
48
41
|
catch {
|
|
49
42
|
console.error("Failed to add MCP server. Is Claude Code installed?");
|
|
@@ -76,5 +69,26 @@ if (command && command !== "serve") {
|
|
|
76
69
|
console.error("Run 'npx @openlap/openlap help' for usage.");
|
|
77
70
|
process.exit(1);
|
|
78
71
|
}
|
|
72
|
+
// -- Auto-update -------------------------------------------------------------
|
|
73
|
+
try {
|
|
74
|
+
const { readFileSync } = await import("fs");
|
|
75
|
+
const { join, dirname } = await import("path");
|
|
76
|
+
const { fileURLToPath } = await import("url");
|
|
77
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
78
|
+
const current = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
79
|
+
const latest = execSync("npm view @openlap/openlap version", {
|
|
80
|
+
encoding: "utf-8",
|
|
81
|
+
timeout: 5000,
|
|
82
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
83
|
+
}).trim();
|
|
84
|
+
if (latest && latest !== current) {
|
|
85
|
+
process.stderr.write(`[openlap] updating ${current} -> ${latest}\n`);
|
|
86
|
+
execSync("npm install -g @openlap/openlap@latest", { stdio: "pipe", timeout: 30000 });
|
|
87
|
+
process.stderr.write(`[openlap] updated to ${latest}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// not critical
|
|
92
|
+
}
|
|
79
93
|
const { startProxy } = await import("./proxy.js");
|
|
80
94
|
await startProxy();
|
package/dist/proxy.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join } from "path";
|
|
|
8
8
|
import { homedir } from "os";
|
|
9
9
|
import { FileOAuthProvider } from "./auth.js";
|
|
10
10
|
import { detectProject, autoSave } from "./git.js";
|
|
11
|
+
import { ChannelManager } from "./channel.js";
|
|
11
12
|
import { FeedManager } from "./feed.js";
|
|
12
13
|
const BASE_URL = process.env.OPENLAP_URL ?? "https://openlap.app";
|
|
13
14
|
// Tools that accept a 'project' parameter
|
|
@@ -15,22 +16,32 @@ const PROJECT_TOOLS = new Set([
|
|
|
15
16
|
"list_laps", "get_lap", "create_lap", "save_lap", "update_lap",
|
|
16
17
|
"post_update", "list_updates",
|
|
17
18
|
"create_project", "update_project", "remove_project",
|
|
19
|
+
"check_lap", "post_channel",
|
|
18
20
|
]);
|
|
21
|
+
// v2 channel tools handled locally by the proxy
|
|
22
|
+
const PROXY_CHANNEL_TOOLS = new Set([
|
|
23
|
+
"join_channel", "leave_channel", "mute_channel", "focus_channel",
|
|
24
|
+
]);
|
|
25
|
+
function getAuthToken() {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(readFileSync(join(homedir(), ".openlap", "auth.json"), "utf-8"));
|
|
28
|
+
return data?.tokens?.access_token;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
19
34
|
export async function startProxy() {
|
|
20
35
|
const authProvider = new FileOAuthProvider();
|
|
21
36
|
// -- Connect to remote MCP server ----------------------------------------
|
|
22
37
|
const remoteTransport = new StreamableHTTPClientTransport(new URL(`${BASE_URL}/mcp`), { authProvider });
|
|
23
38
|
const remote = new Client({ name: "openlap-proxy", version: "1.0.0" }, { capabilities: {} });
|
|
24
|
-
// Try connecting -- if auth is needed, the provider opens the browser
|
|
25
39
|
try {
|
|
26
40
|
await remote.connect(remoteTransport);
|
|
27
41
|
}
|
|
28
42
|
catch (err) {
|
|
29
|
-
// After redirectToAuthorization, the SDK throws UnauthorizedError.
|
|
30
|
-
// We have the auth code from the callback -- finish the flow.
|
|
31
43
|
if (authProvider.authCode) {
|
|
32
44
|
await remoteTransport.finishAuth(authProvider.authCode);
|
|
33
|
-
// Retry connection
|
|
34
45
|
await remote.connect(remoteTransport);
|
|
35
46
|
}
|
|
36
47
|
else {
|
|
@@ -39,7 +50,15 @@ export async function startProxy() {
|
|
|
39
50
|
process.exit(1);
|
|
40
51
|
}
|
|
41
52
|
}
|
|
42
|
-
// --
|
|
53
|
+
// -- Version + project -------------------------------------------------------
|
|
54
|
+
try {
|
|
55
|
+
const { dirname } = await import("path");
|
|
56
|
+
const { fileURLToPath } = await import("url");
|
|
57
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
58
|
+
const ver = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
59
|
+
process.stderr.write(`[openlap] v${ver}\n`);
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
43
62
|
const detectedProject = detectProject();
|
|
44
63
|
if (detectedProject) {
|
|
45
64
|
process.stderr.write(`[openlap] project: ${detectedProject}\n`);
|
|
@@ -51,19 +70,19 @@ export async function startProxy() {
|
|
|
51
70
|
experimental: { "claude/channel": {} },
|
|
52
71
|
},
|
|
53
72
|
});
|
|
54
|
-
// --
|
|
55
|
-
const
|
|
73
|
+
// -- v2: ChannelManager ---------------------------------------------------
|
|
74
|
+
const channels = new ChannelManager(BASE_URL, (channel, update) => {
|
|
56
75
|
const who = update.project_name || "unknown";
|
|
57
76
|
const health = update.health && update.health !== "on_track" ? ` [${update.health}]` : "";
|
|
58
|
-
const
|
|
59
|
-
|
|
77
|
+
const displayName = update.display_name ? `${update.display_name}` : who;
|
|
78
|
+
const content = `[${channel}] ${displayName}${health}: ${update.body}`;
|
|
60
79
|
server.notification({
|
|
61
80
|
method: "notifications/claude/channel",
|
|
62
81
|
params: {
|
|
63
82
|
content,
|
|
64
83
|
meta: {
|
|
65
84
|
update_id: update.id,
|
|
66
|
-
tag,
|
|
85
|
+
tag: channel,
|
|
67
86
|
created_at: update.created_at,
|
|
68
87
|
health: update.health ?? "on_track",
|
|
69
88
|
},
|
|
@@ -73,47 +92,149 @@ export async function startProxy() {
|
|
|
73
92
|
process.stderr.write(`[openlap] notification error: ${err}\n`);
|
|
74
93
|
}
|
|
75
94
|
});
|
|
76
|
-
}, () => {
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
catch {
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
95
|
+
}, getAuthToken, (channel, event) => {
|
|
96
|
+
// Presence events
|
|
97
|
+
const content = `[${channel}] ${event.display_name} ${event.action === "join" ? "joined" : event.action === "timeout" ? "timed out" : "left"}`;
|
|
98
|
+
server.notification({
|
|
99
|
+
method: "notifications/claude/channel",
|
|
100
|
+
params: { content, meta: { tag: channel, action: event.action } },
|
|
101
|
+
}).catch(() => { });
|
|
85
102
|
});
|
|
86
|
-
//
|
|
103
|
+
// -- v1 compat: FeedManager for old track subscriptions -------------------
|
|
104
|
+
const feeds = new FeedManager(BASE_URL, (tag, update) => {
|
|
105
|
+
const who = update.project_name || "unknown";
|
|
106
|
+
const health = update.health && update.health !== "on_track" ? ` [${update.health}]` : "";
|
|
107
|
+
const content = `[${who}]${health} ${update.body}`;
|
|
108
|
+
server.notification({
|
|
109
|
+
method: "notifications/claude/channel",
|
|
110
|
+
params: {
|
|
111
|
+
content,
|
|
112
|
+
meta: { update_id: update.id, tag, created_at: update.created_at, health: update.health ?? "on_track" },
|
|
113
|
+
},
|
|
114
|
+
}).catch((err) => {
|
|
115
|
+
if (!String(err).includes("Not connected")) {
|
|
116
|
+
process.stderr.write(`[openlap] notification error: ${err}\n`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}, getAuthToken);
|
|
120
|
+
// Forward tool listing from remote + inject proxy-side channel tools
|
|
87
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
88
122
|
const result = await remote.listTools();
|
|
89
|
-
|
|
123
|
+
// v2: proxy-side channel tools (not registered on server)
|
|
124
|
+
const proxyTools = [
|
|
125
|
+
{
|
|
126
|
+
name: "join_channel",
|
|
127
|
+
description: "Join a channel. Registers presence, opens SSE subscription, returns who's here and recent posts. You'll receive live updates from this channel. Use leave_channel to unsubscribe.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
channel: { type: "string", description: "Channel name (e.g. \"openlap-v2\")" },
|
|
132
|
+
name: { type: "string", description: "Display name (defaults to GitHub login). Use for role names like \"worker\" or \"adversary\"." },
|
|
133
|
+
},
|
|
134
|
+
required: ["channel"],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "leave_channel",
|
|
139
|
+
description: "Leave a channel. Removes presence and stops SSE subscription. Other members see you leave.",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
channel: { type: "string", description: "Channel name" },
|
|
144
|
+
},
|
|
145
|
+
required: ["channel"],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "mute_channel",
|
|
150
|
+
description: "Mute a channel. Still present (heartbeat continues) but notifications stop. Other members still see you as present. Use focus_channel to unmute.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
channel: { type: "string", description: "Channel name" },
|
|
155
|
+
},
|
|
156
|
+
required: ["channel"],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "focus_channel",
|
|
161
|
+
description: "Focus on a channel. Unmutes it and pins it to briefings. Only one channel can be focused at a time.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
channel: { type: "string", description: "Channel name" },
|
|
166
|
+
},
|
|
167
|
+
required: ["channel"],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
return { tools: [...result.tools, ...proxyTools] };
|
|
90
172
|
});
|
|
91
173
|
// Forward tool calls with local enhancements
|
|
92
174
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
93
175
|
const { name } = req.params;
|
|
94
176
|
const args = { ...(req.params.arguments ?? {}) };
|
|
95
|
-
//
|
|
177
|
+
// -- v2 proxy-side channel tools (handled locally) ----------------------
|
|
178
|
+
if (name === "join_channel") {
|
|
179
|
+
const channel = args.channel;
|
|
180
|
+
if (!channel)
|
|
181
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
182
|
+
const result = await channels.join(channel, args.name);
|
|
183
|
+
return { content: [{ type: "text", text: JSON.stringify(result ?? { error: "already joined or failed" }) }] };
|
|
184
|
+
}
|
|
185
|
+
if (name === "leave_channel") {
|
|
186
|
+
const channel = args.channel;
|
|
187
|
+
if (!channel)
|
|
188
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
189
|
+
await channels.leave(channel);
|
|
190
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "left", channel }) }] };
|
|
191
|
+
}
|
|
192
|
+
if (name === "mute_channel") {
|
|
193
|
+
const channel = args.channel;
|
|
194
|
+
if (!channel)
|
|
195
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
196
|
+
const ok = channels.mute(channel);
|
|
197
|
+
return { content: [{ type: "text", text: ok ? `Muted ${channel}. Still present, notifications paused.` : `Not in ${channel}.` }] };
|
|
198
|
+
}
|
|
199
|
+
if (name === "focus_channel") {
|
|
200
|
+
const channel = args.channel;
|
|
201
|
+
if (!channel)
|
|
202
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
203
|
+
const ok = channels.focus(channel);
|
|
204
|
+
return { content: [{ type: "text", text: ok ? `Focused on ${channel}. Unmuted and pinned to briefings.` : `Not in ${channel}.` }] };
|
|
205
|
+
}
|
|
206
|
+
// -- Auto-inject project ------------------------------------------------
|
|
96
207
|
if (detectedProject && PROJECT_TOOLS.has(name) && !args.project) {
|
|
97
208
|
args.project = detectedProject;
|
|
98
209
|
}
|
|
99
|
-
// Auto-
|
|
100
|
-
if (name === "
|
|
210
|
+
// -- Auto-inject channel from project for post_channel ------------------
|
|
211
|
+
if (name === "post_channel" && !args.channel && detectedProject) {
|
|
212
|
+
args.channel = detectedProject;
|
|
213
|
+
}
|
|
214
|
+
// -- Auto-save before post_update and post_channel ----------------------
|
|
215
|
+
if ((name === "post_update" || name === "post_channel") && typeof args.body === "string") {
|
|
101
216
|
const saved = autoSave(args.body);
|
|
102
217
|
if (saved) {
|
|
103
218
|
process.stderr.write(`[openlap] auto-saved: ${saved}\n`);
|
|
104
219
|
}
|
|
105
220
|
}
|
|
106
|
-
// Forward to remote
|
|
221
|
+
// -- Forward to remote --------------------------------------------------
|
|
107
222
|
const result = await remote.callTool({ name, arguments: args });
|
|
108
|
-
//
|
|
223
|
+
// v1 compat: auto-subscribe to feeds from track tool usage
|
|
109
224
|
if (typeof args.tag === "string" && args.tag) {
|
|
110
|
-
|
|
225
|
+
if (!channels.isJoined(args.tag)) {
|
|
226
|
+
feeds.subscribe(args.tag);
|
|
227
|
+
}
|
|
111
228
|
}
|
|
112
229
|
if (name === "get_track" && typeof args.name === "string" && args.name) {
|
|
113
|
-
|
|
230
|
+
if (!channels.isJoined(args.name)) {
|
|
231
|
+
feeds.subscribe(args.name);
|
|
232
|
+
}
|
|
114
233
|
}
|
|
115
234
|
if (name === "create_track" && typeof args.name === "string" && args.name) {
|
|
116
|
-
|
|
235
|
+
if (!channels.isJoined(args.name)) {
|
|
236
|
+
feeds.subscribe(args.name);
|
|
237
|
+
}
|
|
117
238
|
}
|
|
118
239
|
return {
|
|
119
240
|
content: result.content,
|