@lightupai/polaris 0.0.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/.github/workflows/ci.yml +38 -0
- package/.mcp.json +12 -0
- package/LICENSE +201 -0
- package/Makefile +38 -0
- package/PLAN.md +438 -0
- package/docker-compose.yml +14 -0
- package/docs/README.md +377 -0
- package/hooks/capture.sh +19 -0
- package/hooks/statusline.sh +30 -0
- package/package.json +22 -0
- package/scripts/setup-google-oauth.sh +111 -0
- package/scripts/setup-slack-app.sh +115 -0
- package/skills/polaris/SKILL.md +29 -0
- package/src/cli/cli.ts +294 -0
- package/src/client/client.ts +245 -0
- package/src/daemon/daemon.ts +275 -0
- package/src/service/auth.ts +45 -0
- package/src/service/db.ts +275 -0
- package/src/service/server.ts +406 -0
- package/src/slack/system.ts +107 -0
- package/src/types.ts +108 -0
- package/src/web/app.ts +397 -0
- package/src/web/fixtures.ts +121 -0
- package/src/web/layout.ts +68 -0
- package/src/web/pages.ts +156 -0
- package/src/web/serve.ts +13 -0
- package/src/web/views.ts +356 -0
- package/tests/auth.test.ts +37 -0
- package/tests/client.test.ts +187 -0
- package/tests/daemon.test.ts +220 -0
- package/tests/db.test.ts +282 -0
- package/tests/e2e.test.ts +415 -0
- package/tests/service.test.ts +365 -0
- package/tests/types.test.ts +240 -0
- package/tests/web.test.ts +420 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { type Server, type ServerWebSocket } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createDb,
|
|
5
|
+
createOrg,
|
|
6
|
+
createProject,
|
|
7
|
+
getProject,
|
|
8
|
+
createSession,
|
|
9
|
+
getSession,
|
|
10
|
+
setDriver,
|
|
11
|
+
clearDriver,
|
|
12
|
+
pushEvent,
|
|
13
|
+
getProjectEvents,
|
|
14
|
+
getSessionEvents,
|
|
15
|
+
getEventsSince,
|
|
16
|
+
type Sql,
|
|
17
|
+
} from "./db";
|
|
18
|
+
import { verifyToken, type TokenPayload } from "./auth";
|
|
19
|
+
import type { PolarisEvent, ParticipantId } from "../types";
|
|
20
|
+
import { HookPayload, ParticipantId as ParticipantIdSchema } from "../types";
|
|
21
|
+
|
|
22
|
+
// --- WebSocket subscriber management ---
|
|
23
|
+
|
|
24
|
+
type WsData = { project: string; session?: string };
|
|
25
|
+
const projectSubs = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
26
|
+
const sessionSubs = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
27
|
+
|
|
28
|
+
function subKey(project: string, session?: string): string {
|
|
29
|
+
return session ? `${project}/${session}` : project;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function addSub(ws: ServerWebSocket<WsData>) {
|
|
33
|
+
const { project, session } = ws.data;
|
|
34
|
+
if (session) {
|
|
35
|
+
const key = subKey(project, session);
|
|
36
|
+
if (!sessionSubs.has(key)) sessionSubs.set(key, new Set());
|
|
37
|
+
sessionSubs.get(key)!.add(ws);
|
|
38
|
+
} else {
|
|
39
|
+
if (!projectSubs.has(project)) projectSubs.set(project, new Set());
|
|
40
|
+
projectSubs.get(project)!.add(ws);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removeSub(ws: ServerWebSocket<WsData>) {
|
|
45
|
+
const { project, session } = ws.data;
|
|
46
|
+
if (session) {
|
|
47
|
+
sessionSubs.get(subKey(project, session))?.delete(ws);
|
|
48
|
+
} else {
|
|
49
|
+
projectSubs.get(project)?.delete(ws);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function broadcastEvent(event: PolarisEvent) {
|
|
54
|
+
const msg = JSON.stringify(event);
|
|
55
|
+
for (const ws of projectSubs.get(event.project) ?? []) {
|
|
56
|
+
ws.send(msg);
|
|
57
|
+
}
|
|
58
|
+
const sessionKey = subKey(event.project, event.session);
|
|
59
|
+
for (const ws of sessionSubs.get(sessionKey) ?? []) {
|
|
60
|
+
if (!projectSubs.get(event.project)?.has(ws)) {
|
|
61
|
+
ws.send(msg);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- SSE helpers ---
|
|
67
|
+
|
|
68
|
+
type SseController = ReadableStreamDefaultController<Uint8Array>;
|
|
69
|
+
const projectSseClients = new Map<string, Set<SseController>>();
|
|
70
|
+
const sessionSseClients = new Map<string, Set<SseController>>();
|
|
71
|
+
|
|
72
|
+
function addSse(controller: SseController, project: string, session?: string) {
|
|
73
|
+
if (session) {
|
|
74
|
+
const key = subKey(project, session);
|
|
75
|
+
if (!sessionSseClients.has(key)) sessionSseClients.set(key, new Set());
|
|
76
|
+
sessionSseClients.get(key)!.add(controller);
|
|
77
|
+
} else {
|
|
78
|
+
if (!projectSseClients.has(project)) projectSseClients.set(project, new Set());
|
|
79
|
+
projectSseClients.get(project)!.add(controller);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function removeSse(controller: SseController, project: string, session?: string) {
|
|
84
|
+
if (session) {
|
|
85
|
+
sessionSseClients.get(subKey(project, session))?.delete(controller);
|
|
86
|
+
} else {
|
|
87
|
+
projectSseClients.get(project)?.delete(controller);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function broadcastSse(event: PolarisEvent) {
|
|
92
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
93
|
+
const bytes = new TextEncoder().encode(data);
|
|
94
|
+
for (const ctrl of projectSseClients.get(event.project) ?? []) {
|
|
95
|
+
try { ctrl.enqueue(bytes); } catch { /* client disconnected */ }
|
|
96
|
+
}
|
|
97
|
+
const sessionKey = subKey(event.project, event.session);
|
|
98
|
+
for (const ctrl of sessionSseClients.get(sessionKey) ?? []) {
|
|
99
|
+
if (!projectSseClients.get(event.project)?.has(ctrl)) {
|
|
100
|
+
try { ctrl.enqueue(bytes); } catch { /* client disconnected */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Route matching ---
|
|
106
|
+
|
|
107
|
+
type RouteParams = Record<string, string>;
|
|
108
|
+
|
|
109
|
+
function matchRoute(method: string, pathname: string, pattern: string, expectedMethod: string): RouteParams | null {
|
|
110
|
+
if (method !== expectedMethod) return null;
|
|
111
|
+
const patternParts = pattern.split("/");
|
|
112
|
+
const pathParts = pathname.split("/");
|
|
113
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
114
|
+
const params: RouteParams = {};
|
|
115
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
116
|
+
if (patternParts[i].startsWith(":")) {
|
|
117
|
+
params[patternParts[i].slice(1)] = pathParts[i];
|
|
118
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return params;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Helpers ---
|
|
126
|
+
|
|
127
|
+
async function jsonBody(req: Request): Promise<unknown> {
|
|
128
|
+
try {
|
|
129
|
+
return await req.json();
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function json(data: unknown, status = 200): Response {
|
|
136
|
+
return new Response(JSON.stringify(data), {
|
|
137
|
+
status,
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function error(message: string, status: number): Response {
|
|
143
|
+
return json({ error: message }, status);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Auth helper ---
|
|
147
|
+
|
|
148
|
+
async function resolveOrgId(req: Request, defaultOrgId: string): Promise<string> {
|
|
149
|
+
const auth = req.headers.get("Authorization");
|
|
150
|
+
if (auth?.startsWith("Bearer ")) {
|
|
151
|
+
const token = auth.slice(7);
|
|
152
|
+
const payload = await verifyToken(token);
|
|
153
|
+
if (payload) return payload.org_id;
|
|
154
|
+
}
|
|
155
|
+
return defaultOrgId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- Server factory ---
|
|
159
|
+
|
|
160
|
+
export async function startServer(opts: {
|
|
161
|
+
port?: number;
|
|
162
|
+
databaseUrl?: string;
|
|
163
|
+
defaultOrgId?: string;
|
|
164
|
+
} = {}): Promise<{
|
|
165
|
+
server: Server;
|
|
166
|
+
sql: Sql;
|
|
167
|
+
defaultOrgId: string;
|
|
168
|
+
stop: () => Promise<void>;
|
|
169
|
+
}> {
|
|
170
|
+
const sql = await createDb(opts.databaseUrl);
|
|
171
|
+
const port = opts.port ?? Number(process.env.PORT ?? 4321);
|
|
172
|
+
const defaultOrgId = opts.defaultOrgId ?? "default";
|
|
173
|
+
|
|
174
|
+
// Ensure default org exists for backward compat (tests, daemon without auth)
|
|
175
|
+
try {
|
|
176
|
+
await createOrg(sql, defaultOrgId, "Default", undefined);
|
|
177
|
+
} catch {
|
|
178
|
+
// Already exists
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const server = Bun.serve<WsData>({
|
|
182
|
+
port,
|
|
183
|
+
hostname: "0.0.0.0",
|
|
184
|
+
|
|
185
|
+
websocket: {
|
|
186
|
+
open(ws) { addSub(ws); },
|
|
187
|
+
close(ws) { removeSub(ws); },
|
|
188
|
+
message() {},
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async fetch(req, server) {
|
|
192
|
+
const url = new URL(req.url);
|
|
193
|
+
const { pathname } = url;
|
|
194
|
+
const method = req.method;
|
|
195
|
+
let params: RouteParams | null;
|
|
196
|
+
|
|
197
|
+
// Resolve org from auth token or use default
|
|
198
|
+
const orgId = await resolveOrgId(req, defaultOrgId);
|
|
199
|
+
|
|
200
|
+
// --- WebSocket upgrade ---
|
|
201
|
+
params = matchRoute(method, pathname, "/projects/:proj/ws", "GET");
|
|
202
|
+
if (params) {
|
|
203
|
+
const upgraded = server.upgrade(req, { data: { project: params.proj } });
|
|
204
|
+
return upgraded ? undefined : error("WebSocket upgrade failed", 400);
|
|
205
|
+
}
|
|
206
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/ws", "GET");
|
|
207
|
+
if (params) {
|
|
208
|
+
const upgraded = server.upgrade(req, { data: { project: params.proj, session: params.sess } });
|
|
209
|
+
return upgraded ? undefined : error("WebSocket upgrade failed", 400);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Project endpoints ---
|
|
213
|
+
|
|
214
|
+
params = matchRoute(method, pathname, "/projects", "POST");
|
|
215
|
+
if (params) {
|
|
216
|
+
const body = await jsonBody(req);
|
|
217
|
+
const parsed = z.object({ name: z.string().min(1) }).safeParse(body);
|
|
218
|
+
if (!parsed.success) return error("Invalid body: name is required", 400);
|
|
219
|
+
try {
|
|
220
|
+
const project = await createProject(sql, orgId, parsed.data.name);
|
|
221
|
+
return json(project, 201);
|
|
222
|
+
} catch {
|
|
223
|
+
return error("Project already exists", 409);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
params = matchRoute(method, pathname, "/projects/:proj", "GET");
|
|
228
|
+
if (params) {
|
|
229
|
+
const project = await getProject(sql, orgId, params.proj);
|
|
230
|
+
if (!project) return error("Project not found", 404);
|
|
231
|
+
return json(project);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
params = matchRoute(method, pathname, "/projects/:proj/messages", "GET");
|
|
235
|
+
if (params) {
|
|
236
|
+
const project = await getProject(sql, orgId, params.proj);
|
|
237
|
+
if (!project) return error("Project not found", 404);
|
|
238
|
+
const since = url.searchParams.get("since");
|
|
239
|
+
const events = since
|
|
240
|
+
? await getEventsSince(sql, orgId, params.proj, since)
|
|
241
|
+
: await getProjectEvents(sql, orgId, params.proj);
|
|
242
|
+
return json(events);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
params = matchRoute(method, pathname, "/projects/:proj/events", "GET");
|
|
246
|
+
if (params) {
|
|
247
|
+
const project = await getProject(sql, orgId, params.proj);
|
|
248
|
+
if (!project) return error("Project not found", 404);
|
|
249
|
+
const proj = params.proj;
|
|
250
|
+
let controller: SseController;
|
|
251
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
252
|
+
start(ctrl) { controller = ctrl; addSse(controller, proj); },
|
|
253
|
+
cancel() { removeSse(controller, proj); },
|
|
254
|
+
});
|
|
255
|
+
return new Response(stream, {
|
|
256
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- Session endpoints ---
|
|
261
|
+
|
|
262
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions", "POST");
|
|
263
|
+
if (params) {
|
|
264
|
+
const project = await getProject(sql, orgId, params.proj);
|
|
265
|
+
if (!project) return error("Project not found", 404);
|
|
266
|
+
const body = await jsonBody(req);
|
|
267
|
+
const parsed = z
|
|
268
|
+
.object({ name: z.string().min(1), driver: ParticipantIdSchema.nullable().default(null) })
|
|
269
|
+
.safeParse(body);
|
|
270
|
+
if (!parsed.success) return error("Invalid body: name is required", 400);
|
|
271
|
+
try {
|
|
272
|
+
const session = await createSession(sql, orgId, params.proj, parsed.data.name, parsed.data.driver);
|
|
273
|
+
return json(session, 201);
|
|
274
|
+
} catch {
|
|
275
|
+
return error("Session already exists in this project", 409);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess", "GET");
|
|
280
|
+
if (params) {
|
|
281
|
+
const session = await getSession(sql, orgId, params.proj, params.sess);
|
|
282
|
+
if (!session) return error("Session not found", 404);
|
|
283
|
+
return json(session);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/events", "POST");
|
|
287
|
+
if (params) {
|
|
288
|
+
const session = await getSession(sql, orgId, params.proj, params.sess);
|
|
289
|
+
if (!session) return error("Session not found", 404);
|
|
290
|
+
const body = await jsonBody(req);
|
|
291
|
+
const parsed = z
|
|
292
|
+
.object({ sender: ParticipantIdSchema, payload: HookPayload })
|
|
293
|
+
.safeParse(body);
|
|
294
|
+
if (!parsed.success) return error(`Invalid body: ${parsed.error.message}`, 400);
|
|
295
|
+
const event: PolarisEvent = {
|
|
296
|
+
id: crypto.randomUUID(),
|
|
297
|
+
project: params.proj,
|
|
298
|
+
session: params.sess,
|
|
299
|
+
timestamp: new Date().toISOString(),
|
|
300
|
+
source: "hook",
|
|
301
|
+
sender: parsed.data.sender,
|
|
302
|
+
payload: parsed.data.payload,
|
|
303
|
+
};
|
|
304
|
+
await pushEvent(sql, orgId, event);
|
|
305
|
+
broadcastEvent(event);
|
|
306
|
+
broadcastSse(event);
|
|
307
|
+
return json(event, 201);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/events", "GET");
|
|
311
|
+
if (params) {
|
|
312
|
+
const session = await getSession(sql, orgId, params.proj, params.sess);
|
|
313
|
+
if (!session) return error("Session not found", 404);
|
|
314
|
+
const proj = params.proj;
|
|
315
|
+
const sess = params.sess;
|
|
316
|
+
let controller: SseController;
|
|
317
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
318
|
+
start(ctrl) { controller = ctrl; addSse(controller, proj, sess); },
|
|
319
|
+
cancel() { removeSse(controller, proj, sess); },
|
|
320
|
+
});
|
|
321
|
+
return new Response(stream, {
|
|
322
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/inject", "POST");
|
|
327
|
+
if (params) {
|
|
328
|
+
const session = await getSession(sql, orgId, params.proj, params.sess);
|
|
329
|
+
if (!session) return error("Session not found", 404);
|
|
330
|
+
const body = await jsonBody(req);
|
|
331
|
+
const parsed = z
|
|
332
|
+
.object({ content: z.string().min(1), sender: ParticipantIdSchema })
|
|
333
|
+
.safeParse(body);
|
|
334
|
+
if (!parsed.success) return error(`Invalid body: ${parsed.error.message}`, 400);
|
|
335
|
+
const event: PolarisEvent = {
|
|
336
|
+
id: crypto.randomUUID(),
|
|
337
|
+
project: params.proj,
|
|
338
|
+
session: params.sess,
|
|
339
|
+
timestamp: new Date().toISOString(),
|
|
340
|
+
source: "inject",
|
|
341
|
+
sender: parsed.data.sender,
|
|
342
|
+
payload: {
|
|
343
|
+
type: "inject" as const,
|
|
344
|
+
content: parsed.data.content,
|
|
345
|
+
sender: parsed.data.sender,
|
|
346
|
+
target: params.sess,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
await pushEvent(sql, orgId, event);
|
|
350
|
+
broadcastEvent(event);
|
|
351
|
+
broadcastSse(event);
|
|
352
|
+
return json(event, 201);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/messages", "GET");
|
|
356
|
+
if (params) {
|
|
357
|
+
const session = await getSession(sql, orgId, params.proj, params.sess);
|
|
358
|
+
if (!session) return error("Session not found", 404);
|
|
359
|
+
const events = await getSessionEvents(sql, orgId, params.proj, params.sess);
|
|
360
|
+
return json(events);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/handoff", "POST");
|
|
364
|
+
if (params) {
|
|
365
|
+
const session = await getSession(sql, orgId, params.proj, params.sess);
|
|
366
|
+
if (!session) return error("Session not found", 404);
|
|
367
|
+
if (!session.driver) return error("Session has no driver to hand off", 400);
|
|
368
|
+
await clearDriver(sql, orgId, params.proj, params.sess);
|
|
369
|
+
return json({ status: "open", session: params.sess });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/driver", "POST");
|
|
373
|
+
if (params) {
|
|
374
|
+
const session = await getSession(sql, orgId, params.proj, params.sess);
|
|
375
|
+
if (!session) return error("Session not found", 404);
|
|
376
|
+
if (session.driver) return error(`Session already has driver: ${session.driver}`, 409);
|
|
377
|
+
const body = await jsonBody(req);
|
|
378
|
+
const parsed = z.object({ driver: ParticipantIdSchema }).safeParse(body);
|
|
379
|
+
if (!parsed.success) return error("Invalid body: driver is required", 400);
|
|
380
|
+
await setDriver(sql, orgId, params.proj, params.sess, parsed.data.driver);
|
|
381
|
+
return json({ status: "claimed", driver: parsed.data.driver, session: params.sess });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (method === "GET" && pathname === "/status") {
|
|
385
|
+
return json({ ok: true, version: "0.0.1" });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return error("Not found", 404);
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
server,
|
|
394
|
+
sql,
|
|
395
|
+
defaultOrgId,
|
|
396
|
+
stop: async () => {
|
|
397
|
+
server.stop(true);
|
|
398
|
+
await sql.end();
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (import.meta.main) {
|
|
404
|
+
const { server } = await startServer();
|
|
405
|
+
console.error(`Polaris server listening on port ${server.port}`);
|
|
406
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// --- Slack system channel: create, post events ---
|
|
2
|
+
|
|
3
|
+
const SYSTEM_CHANNEL_NAME = "polaris-system";
|
|
4
|
+
|
|
5
|
+
async function slackApi(token: string, method: string, body?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
6
|
+
const res = await fetch(`https://slack.com/api/${method}`, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${token}`,
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
},
|
|
12
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
13
|
+
});
|
|
14
|
+
return (await res.json()) as Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Look up a Slack user ID by email.
|
|
18
|
+
async function lookupSlackUser(botToken: string, email: string): Promise<string | null> {
|
|
19
|
+
const res = await slackApi(botToken, "users.lookupByEmail", { email });
|
|
20
|
+
if (res.ok) return (res.user as { id: string }).id;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Create the #polaris-system channel and return its ID.
|
|
25
|
+
// If it already exists, join it and return its ID.
|
|
26
|
+
// Optionally invites a user (by email) to the channel.
|
|
27
|
+
export async function createSystemChannel(botToken: string, inviteEmail?: string): Promise<string> {
|
|
28
|
+
// Try to create
|
|
29
|
+
const createRes = await slackApi(botToken, "conversations.create", {
|
|
30
|
+
name: SYSTEM_CHANNEL_NAME,
|
|
31
|
+
is_private: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (createRes.ok) {
|
|
35
|
+
const channelId = (createRes.channel as { id: string }).id;
|
|
36
|
+
await slackApi(botToken, "conversations.setTopic", {
|
|
37
|
+
channel: channelId,
|
|
38
|
+
topic: "Polaris system events — device connections, sessions, handoffs",
|
|
39
|
+
});
|
|
40
|
+
if (inviteEmail) {
|
|
41
|
+
const slackUserId = await lookupSlackUser(botToken, inviteEmail);
|
|
42
|
+
if (slackUserId) {
|
|
43
|
+
await slackApi(botToken, "conversations.invite", { channel: channelId, users: slackUserId });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return channelId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Channel already exists — find it and join
|
|
50
|
+
if (createRes.error === "name_taken") {
|
|
51
|
+
// Search for it
|
|
52
|
+
let cursor: string | undefined;
|
|
53
|
+
do {
|
|
54
|
+
const listRes = await slackApi(botToken, "conversations.list", {
|
|
55
|
+
types: "public_channel",
|
|
56
|
+
limit: 200,
|
|
57
|
+
exclude_archived: true,
|
|
58
|
+
...(cursor ? { cursor } : {}),
|
|
59
|
+
}) as { ok: boolean; channels?: Array<{ id: string; name: string }>; response_metadata?: { next_cursor?: string } };
|
|
60
|
+
|
|
61
|
+
if (listRes.ok && listRes.channels) {
|
|
62
|
+
const found = listRes.channels.find((c) => c.name === SYSTEM_CHANNEL_NAME);
|
|
63
|
+
if (found) {
|
|
64
|
+
await slackApi(botToken, "conversations.join", { channel: found.id });
|
|
65
|
+
if (inviteEmail) {
|
|
66
|
+
const slackUserId = await lookupSlackUser(botToken, inviteEmail);
|
|
67
|
+
if (slackUserId) {
|
|
68
|
+
await slackApi(botToken, "conversations.invite", { channel: found.id, users: slackUserId });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return found.id;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
cursor = listRes.response_metadata?.next_cursor || undefined;
|
|
75
|
+
} while (cursor);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new Error(`Failed to create or find #${SYSTEM_CHANNEL_NAME} channel: ${createRes.error}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Post a system event to the #polaris channel.
|
|
82
|
+
export async function postSystemEvent(
|
|
83
|
+
botToken: string,
|
|
84
|
+
channelId: string,
|
|
85
|
+
text: string,
|
|
86
|
+
context?: string
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const blocks: Array<Record<string, unknown>> = [
|
|
89
|
+
{
|
|
90
|
+
type: "section",
|
|
91
|
+
text: { type: "mrkdwn", text },
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
if (context) {
|
|
96
|
+
blocks.push({
|
|
97
|
+
type: "context",
|
|
98
|
+
elements: [{ type: "mrkdwn", text: context }],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await slackApi(botToken, "chat.postMessage", {
|
|
103
|
+
channel: channelId,
|
|
104
|
+
text, // fallback for notifications
|
|
105
|
+
blocks,
|
|
106
|
+
});
|
|
107
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// --- Participant Identity ---
|
|
4
|
+
|
|
5
|
+
export const ParticipantId = z
|
|
6
|
+
.string()
|
|
7
|
+
.regex(
|
|
8
|
+
/^(user|agent):[a-z0-9][a-z0-9._-]*$/,
|
|
9
|
+
"Must be user:<name> or agent:<name> (lowercase alphanumeric, dots, hyphens, underscores)"
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export type ParticipantId = z.infer<typeof ParticipantId>;
|
|
13
|
+
|
|
14
|
+
// --- Hook Event Payloads ---
|
|
15
|
+
|
|
16
|
+
const HookCommon = z.object({
|
|
17
|
+
session_id: z.string(),
|
|
18
|
+
transcript_path: z.string().optional(),
|
|
19
|
+
cwd: z.string().optional(),
|
|
20
|
+
permission_mode: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const UserPromptSubmitPayload = HookCommon.extend({
|
|
24
|
+
hook_event_name: z.literal("UserPromptSubmit"),
|
|
25
|
+
prompt: z.string(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const StopPayload = HookCommon.extend({
|
|
29
|
+
hook_event_name: z.literal("Stop"),
|
|
30
|
+
stop_response: z.string(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const PreToolUsePayload = HookCommon.extend({
|
|
34
|
+
hook_event_name: z.literal("PreToolUse"),
|
|
35
|
+
tool_name: z.string(),
|
|
36
|
+
tool_input: z.record(z.unknown()),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const PostToolUsePayload = HookCommon.extend({
|
|
40
|
+
hook_event_name: z.literal("PostToolUse"),
|
|
41
|
+
tool_name: z.string(),
|
|
42
|
+
tool_input: z.record(z.unknown()),
|
|
43
|
+
tool_result: z.unknown(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const HookPayload = z.discriminatedUnion("hook_event_name", [
|
|
47
|
+
UserPromptSubmitPayload,
|
|
48
|
+
StopPayload,
|
|
49
|
+
PreToolUsePayload,
|
|
50
|
+
PostToolUsePayload,
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export type HookPayload = z.infer<typeof HookPayload>;
|
|
54
|
+
|
|
55
|
+
// --- Inject & Reply Messages ---
|
|
56
|
+
|
|
57
|
+
export const InjectMessage = z.object({
|
|
58
|
+
type: z.literal("inject"),
|
|
59
|
+
content: z.string(),
|
|
60
|
+
sender: ParticipantId,
|
|
61
|
+
target: z.string().min(1, "target session is required"),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type InjectMessage = z.infer<typeof InjectMessage>;
|
|
65
|
+
|
|
66
|
+
export const ReplyMessage = z.object({
|
|
67
|
+
type: z.literal("reply"),
|
|
68
|
+
content: z.string(),
|
|
69
|
+
sender: ParticipantId,
|
|
70
|
+
in_reply_to: z.string().optional(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export type ReplyMessage = z.infer<typeof ReplyMessage>;
|
|
74
|
+
|
|
75
|
+
// --- Event Envelope ---
|
|
76
|
+
|
|
77
|
+
export const EventSource = z.enum(["hook", "inject", "reply"]);
|
|
78
|
+
export type EventSource = z.infer<typeof EventSource>;
|
|
79
|
+
|
|
80
|
+
export const PolarisEvent = z.object({
|
|
81
|
+
id: z.string().uuid(),
|
|
82
|
+
project: z.string().min(1),
|
|
83
|
+
session: z.string().min(1),
|
|
84
|
+
timestamp: z.string().datetime(),
|
|
85
|
+
source: EventSource,
|
|
86
|
+
sender: ParticipantId,
|
|
87
|
+
payload: z.union([HookPayload, InjectMessage, ReplyMessage]),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export type PolarisEvent = z.infer<typeof PolarisEvent>;
|
|
91
|
+
|
|
92
|
+
// --- Project & Session Models ---
|
|
93
|
+
|
|
94
|
+
export const Project = z.object({
|
|
95
|
+
name: z.string().min(1),
|
|
96
|
+
created_at: z.string().datetime(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export type Project = z.infer<typeof Project>;
|
|
100
|
+
|
|
101
|
+
export const Session = z.object({
|
|
102
|
+
name: z.string().min(1),
|
|
103
|
+
project: z.string().min(1),
|
|
104
|
+
driver: ParticipantId.nullable(),
|
|
105
|
+
created_at: z.string().datetime(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export type Session = z.infer<typeof Session>;
|