@openlap/openlap 1.0.1 → 1.1.0
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 +100 -27
- 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,16 +92,31 @@ 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
|
});
|
|
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);
|
|
86
120
|
// Forward tool listing from remote
|
|
87
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
88
122
|
const result = await remote.listTools();
|
|
@@ -92,28 +126,67 @@ export async function startProxy() {
|
|
|
92
126
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
93
127
|
const { name } = req.params;
|
|
94
128
|
const args = { ...(req.params.arguments ?? {}) };
|
|
95
|
-
//
|
|
129
|
+
// -- v2 proxy-side channel tools (handled locally) ----------------------
|
|
130
|
+
if (name === "join_channel") {
|
|
131
|
+
const channel = args.channel;
|
|
132
|
+
if (!channel)
|
|
133
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
134
|
+
const result = await channels.join(channel, args.name);
|
|
135
|
+
return { content: [{ type: "text", text: JSON.stringify(result ?? { error: "already joined or failed" }) }] };
|
|
136
|
+
}
|
|
137
|
+
if (name === "leave_channel") {
|
|
138
|
+
const channel = args.channel;
|
|
139
|
+
if (!channel)
|
|
140
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
141
|
+
await channels.leave(channel);
|
|
142
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "left", channel }) }] };
|
|
143
|
+
}
|
|
144
|
+
if (name === "mute_channel") {
|
|
145
|
+
const channel = args.channel;
|
|
146
|
+
if (!channel)
|
|
147
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
148
|
+
const ok = channels.mute(channel);
|
|
149
|
+
return { content: [{ type: "text", text: ok ? `Muted ${channel}. Still present, notifications paused.` : `Not in ${channel}.` }] };
|
|
150
|
+
}
|
|
151
|
+
if (name === "focus_channel") {
|
|
152
|
+
const channel = args.channel;
|
|
153
|
+
if (!channel)
|
|
154
|
+
return { content: [{ type: "text", text: "channel required" }], isError: true };
|
|
155
|
+
const ok = channels.focus(channel);
|
|
156
|
+
return { content: [{ type: "text", text: ok ? `Focused on ${channel}. Unmuted and pinned to briefings.` : `Not in ${channel}.` }] };
|
|
157
|
+
}
|
|
158
|
+
// -- Auto-inject project ------------------------------------------------
|
|
96
159
|
if (detectedProject && PROJECT_TOOLS.has(name) && !args.project) {
|
|
97
160
|
args.project = detectedProject;
|
|
98
161
|
}
|
|
99
|
-
// Auto-
|
|
100
|
-
if (name === "
|
|
162
|
+
// -- Auto-inject channel from project for post_channel ------------------
|
|
163
|
+
if (name === "post_channel" && !args.channel && detectedProject) {
|
|
164
|
+
args.channel = detectedProject;
|
|
165
|
+
}
|
|
166
|
+
// -- Auto-save before post_update and post_channel ----------------------
|
|
167
|
+
if ((name === "post_update" || name === "post_channel") && typeof args.body === "string") {
|
|
101
168
|
const saved = autoSave(args.body);
|
|
102
169
|
if (saved) {
|
|
103
170
|
process.stderr.write(`[openlap] auto-saved: ${saved}\n`);
|
|
104
171
|
}
|
|
105
172
|
}
|
|
106
|
-
// Forward to remote
|
|
173
|
+
// -- Forward to remote --------------------------------------------------
|
|
107
174
|
const result = await remote.callTool({ name, arguments: args });
|
|
108
|
-
//
|
|
175
|
+
// v1 compat: auto-subscribe to feeds from track tool usage
|
|
109
176
|
if (typeof args.tag === "string" && args.tag) {
|
|
110
|
-
|
|
177
|
+
if (!channels.isJoined(args.tag)) {
|
|
178
|
+
feeds.subscribe(args.tag);
|
|
179
|
+
}
|
|
111
180
|
}
|
|
112
181
|
if (name === "get_track" && typeof args.name === "string" && args.name) {
|
|
113
|
-
|
|
182
|
+
if (!channels.isJoined(args.name)) {
|
|
183
|
+
feeds.subscribe(args.name);
|
|
184
|
+
}
|
|
114
185
|
}
|
|
115
186
|
if (name === "create_track" && typeof args.name === "string" && args.name) {
|
|
116
|
-
|
|
187
|
+
if (!channels.isJoined(args.name)) {
|
|
188
|
+
feeds.subscribe(args.name);
|
|
189
|
+
}
|
|
117
190
|
}
|
|
118
191
|
return {
|
|
119
192
|
content: result.content,
|