@openlap/openlap 1.0.0 → 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/README.md +78 -0
- package/dist/channel.d.ts +53 -0
- package/dist/channel.js +256 -0
- package/dist/feed.d.ts +2 -1
- package/dist/feed.js +9 -2
- package/dist/index.js +39 -25
- package/dist/proxy.js +104 -19
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# openlap
|
|
2
|
+
|
|
3
|
+
Product requirements that agents read. Write a lap, agent builds it.
|
|
4
|
+
|
|
5
|
+
A lap is one goal, issues to implement, and verification criteria. Agents pick the next open lap and work through it. When they commit, criteria auto-check via webhook. No dashboard, no CLI -- just the MCP server.
|
|
6
|
+
|
|
7
|
+
Live at [openlap.app](https://openlap.app)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
1. Admin installs the GitHub App (once per org): https://github.com/apps/openlap-app/installations/new
|
|
12
|
+
2. Add the MCP server:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
claude mcp add --transport http lap https://openlap.app/mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
3. Open Claude Code. First message triggers GitHub login in browser. Done.
|
|
19
|
+
|
|
20
|
+
For project-scoped briefings (filters context to one repo):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
claude mcp add --transport http --scope project lap "https://openlap.app/mcp?project=owner/repo"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Project scope overrides global when both exist.
|
|
27
|
+
|
|
28
|
+
4. Register your repo as a project (once per repo):
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
create_project owner/repo
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What agents see
|
|
35
|
+
|
|
36
|
+
**Briefings** -- computed context injected into tool descriptions. Agents see project health, the focused lap's unchecked criteria, staleness signals, and priority collisions without calling any tool. Briefings refresh mid-session after mutations.
|
|
37
|
+
|
|
38
|
+
**Laps** -- sorted by priority (0=focus, 1=urgent, 2=high, 3=normal). Agents work on the first open lap. Each lap has a goal, body, and structured criteria.
|
|
39
|
+
|
|
40
|
+
**Criteria** -- verification checks on each lap. Agents prove work by committing with `LAP-NNN #N` in the commit message:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git commit -m "LAP-010 #1 #2 add auth middleware"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The GitHub webhook auto-checks the referenced criteria. When all code criteria pass, the lap auto-closes to done. If manual criteria remain (screenshots, design review), the lap moves to review for human verification.
|
|
47
|
+
|
|
48
|
+
**Focus mode** -- set priority=0 on one lap per project. Its unchecked criteria appear in briefings automatically, so agents know what to verify from `tools/list` alone.
|
|
49
|
+
|
|
50
|
+
## Develop
|
|
51
|
+
|
|
52
|
+
Prerequisites: Go 1.23+, a GitHub App.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export BASE_URL=http://localhost:7784
|
|
56
|
+
export DB_PATH=./openlap.db
|
|
57
|
+
cd cmd/anylap && go run .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Local MCP (overrides the remote server):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
claude mcp add --transport http lap http://localhost:7784/mcp
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Deploy
|
|
67
|
+
|
|
68
|
+
Hetzner. `sky ship --app openlap-1`. Secrets via `sky deploy --app openlap-1`.
|
|
69
|
+
|
|
70
|
+
## Architecture
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
Agent -> MCP (openlap.app/mcp) -> SQLite
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
One Go binary serves REST API + MCP protocol. OAuth 2.1 with PKCE, chaining to GitHub for identity. GitHub App installation = team boundary. SQLite with Litestream S3 backup.
|
|
77
|
+
|
|
78
|
+
See CLAUDE.md for technical reference.
|
|
@@ -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/feed.d.ts
CHANGED
|
@@ -11,7 +11,8 @@ export declare class FeedManager {
|
|
|
11
11
|
private streams;
|
|
12
12
|
private seenIds;
|
|
13
13
|
private callback;
|
|
14
|
-
|
|
14
|
+
private getToken;
|
|
15
|
+
constructor(baseUrl: string, callback: UpdateCallback, getToken?: () => string | undefined);
|
|
15
16
|
/**
|
|
16
17
|
* Subscribe to a tag's feed if not already subscribed.
|
|
17
18
|
*/
|
package/dist/feed.js
CHANGED
|
@@ -5,9 +5,11 @@ export class FeedManager {
|
|
|
5
5
|
streams = new Map();
|
|
6
6
|
seenIds = new Set();
|
|
7
7
|
callback;
|
|
8
|
-
|
|
8
|
+
getToken;
|
|
9
|
+
constructor(baseUrl, callback, getToken) {
|
|
9
10
|
this.baseUrl = baseUrl;
|
|
10
11
|
this.callback = callback;
|
|
12
|
+
this.getToken = getToken;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Subscribe to a tag's feed if not already subscribed.
|
|
@@ -22,7 +24,12 @@ export class FeedManager {
|
|
|
22
24
|
}
|
|
23
25
|
connect(tag, controller) {
|
|
24
26
|
const url = `${this.baseUrl}/feed/${tag}/sse`;
|
|
25
|
-
|
|
27
|
+
const headers = {};
|
|
28
|
+
const token = this.getToken?.();
|
|
29
|
+
if (token) {
|
|
30
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
31
|
+
}
|
|
32
|
+
fetch(url, { headers, signal: controller.signal })
|
|
26
33
|
.then((res) => {
|
|
27
34
|
if (!res.ok || !res.body) {
|
|
28
35
|
throw new Error(`SSE connect failed: ${res.status}`);
|
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
|
@@ -3,31 +3,45 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
6
9
|
import { FileOAuthProvider } from "./auth.js";
|
|
7
10
|
import { detectProject, autoSave } from "./git.js";
|
|
11
|
+
import { ChannelManager } from "./channel.js";
|
|
8
12
|
import { FeedManager } from "./feed.js";
|
|
9
|
-
const BASE_URL = process.env.
|
|
13
|
+
const BASE_URL = process.env.OPENLAP_URL ?? "https://openlap.app";
|
|
10
14
|
// Tools that accept a 'project' parameter
|
|
11
15
|
const PROJECT_TOOLS = new Set([
|
|
12
16
|
"list_laps", "get_lap", "create_lap", "save_lap", "update_lap",
|
|
13
17
|
"post_update", "list_updates",
|
|
14
18
|
"create_project", "update_project", "remove_project",
|
|
19
|
+
"check_lap", "post_channel",
|
|
15
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
|
+
}
|
|
16
34
|
export async function startProxy() {
|
|
17
35
|
const authProvider = new FileOAuthProvider();
|
|
18
36
|
// -- Connect to remote MCP server ----------------------------------------
|
|
19
37
|
const remoteTransport = new StreamableHTTPClientTransport(new URL(`${BASE_URL}/mcp`), { authProvider });
|
|
20
38
|
const remote = new Client({ name: "openlap-proxy", version: "1.0.0" }, { capabilities: {} });
|
|
21
|
-
// Try connecting -- if auth is needed, the provider opens the browser
|
|
22
39
|
try {
|
|
23
40
|
await remote.connect(remoteTransport);
|
|
24
41
|
}
|
|
25
42
|
catch (err) {
|
|
26
|
-
// After redirectToAuthorization, the SDK throws UnauthorizedError.
|
|
27
|
-
// We have the auth code from the callback -- finish the flow.
|
|
28
43
|
if (authProvider.authCode) {
|
|
29
44
|
await remoteTransport.finishAuth(authProvider.authCode);
|
|
30
|
-
// Retry connection
|
|
31
45
|
await remote.connect(remoteTransport);
|
|
32
46
|
}
|
|
33
47
|
else {
|
|
@@ -36,7 +50,15 @@ export async function startProxy() {
|
|
|
36
50
|
process.exit(1);
|
|
37
51
|
}
|
|
38
52
|
}
|
|
39
|
-
// --
|
|
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 { }
|
|
40
62
|
const detectedProject = detectProject();
|
|
41
63
|
if (detectedProject) {
|
|
42
64
|
process.stderr.write(`[openlap] project: ${detectedProject}\n`);
|
|
@@ -48,19 +70,19 @@ export async function startProxy() {
|
|
|
48
70
|
experimental: { "claude/channel": {} },
|
|
49
71
|
},
|
|
50
72
|
});
|
|
51
|
-
// --
|
|
52
|
-
const
|
|
73
|
+
// -- v2: ChannelManager ---------------------------------------------------
|
|
74
|
+
const channels = new ChannelManager(BASE_URL, (channel, update) => {
|
|
53
75
|
const who = update.project_name || "unknown";
|
|
54
76
|
const health = update.health && update.health !== "on_track" ? ` [${update.health}]` : "";
|
|
55
|
-
const
|
|
56
|
-
|
|
77
|
+
const displayName = update.display_name ? `${update.display_name}` : who;
|
|
78
|
+
const content = `[${channel}] ${displayName}${health}: ${update.body}`;
|
|
57
79
|
server.notification({
|
|
58
80
|
method: "notifications/claude/channel",
|
|
59
81
|
params: {
|
|
60
82
|
content,
|
|
61
83
|
meta: {
|
|
62
84
|
update_id: update.id,
|
|
63
|
-
tag,
|
|
85
|
+
tag: channel,
|
|
64
86
|
created_at: update.created_at,
|
|
65
87
|
health: update.health ?? "on_track",
|
|
66
88
|
},
|
|
@@ -70,7 +92,31 @@ export async function startProxy() {
|
|
|
70
92
|
process.stderr.write(`[openlap] notification error: ${err}\n`);
|
|
71
93
|
}
|
|
72
94
|
});
|
|
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(() => { });
|
|
73
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);
|
|
74
120
|
// Forward tool listing from remote
|
|
75
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
76
122
|
const result = await remote.listTools();
|
|
@@ -80,28 +126,67 @@ export async function startProxy() {
|
|
|
80
126
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
81
127
|
const { name } = req.params;
|
|
82
128
|
const args = { ...(req.params.arguments ?? {}) };
|
|
83
|
-
//
|
|
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 ------------------------------------------------
|
|
84
159
|
if (detectedProject && PROJECT_TOOLS.has(name) && !args.project) {
|
|
85
160
|
args.project = detectedProject;
|
|
86
161
|
}
|
|
87
|
-
// Auto-
|
|
88
|
-
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") {
|
|
89
168
|
const saved = autoSave(args.body);
|
|
90
169
|
if (saved) {
|
|
91
170
|
process.stderr.write(`[openlap] auto-saved: ${saved}\n`);
|
|
92
171
|
}
|
|
93
172
|
}
|
|
94
|
-
// Forward to remote
|
|
173
|
+
// -- Forward to remote --------------------------------------------------
|
|
95
174
|
const result = await remote.callTool({ name, arguments: args });
|
|
96
|
-
//
|
|
175
|
+
// v1 compat: auto-subscribe to feeds from track tool usage
|
|
97
176
|
if (typeof args.tag === "string" && args.tag) {
|
|
98
|
-
|
|
177
|
+
if (!channels.isJoined(args.tag)) {
|
|
178
|
+
feeds.subscribe(args.tag);
|
|
179
|
+
}
|
|
99
180
|
}
|
|
100
181
|
if (name === "get_track" && typeof args.name === "string" && args.name) {
|
|
101
|
-
|
|
182
|
+
if (!channels.isJoined(args.name)) {
|
|
183
|
+
feeds.subscribe(args.name);
|
|
184
|
+
}
|
|
102
185
|
}
|
|
103
186
|
if (name === "create_track" && typeof args.name === "string" && args.name) {
|
|
104
|
-
|
|
187
|
+
if (!channels.isJoined(args.name)) {
|
|
188
|
+
feeds.subscribe(args.name);
|
|
189
|
+
}
|
|
105
190
|
}
|
|
106
191
|
return {
|
|
107
192
|
content: result.content,
|