@lightupai/polaris 0.0.104 → 0.0.106

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "description": "Record and stream Claude Code sessions to Slack. Like Gong for AI coding — capture every prompt, response, and tool call. Multiplayer collaboration across AI coding agents.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -264,7 +264,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
264
264
  ...(agent ? { agent } : {}),
265
265
  ...(create ? { create: true } : {}),
266
266
  });
267
- const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; agent?: string; error?: string; existing?: string[]; account?: string };
267
+ const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; agent?: string; error?: string; existing?: string[]; account?: string; gap?: { events: number; since: string } | null };
268
268
  if (res.status === 404 && body.error === "project_not_found") {
269
269
  const accountLine = body.account ? ` in ${body.account}` : "";
270
270
  const list = body.existing && body.existing.length > 0
@@ -278,7 +278,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
278
278
  currentUser = user;
279
279
  currentProfile = activeProfile;
280
280
  await saveSessionState(currentProject, currentSession, currentUser, currentProfile);
281
- return { content: [{ type: "text", text: `Connected to #${currentProject}/${currentSession} as ${user}.` }] };
281
+ let msg = `Connected to #${currentProject}/${currentSession} as ${user}.`;
282
+ if (body.gap && body.gap.events > 0) {
283
+ msg += `\n${body.gap.events} events since ${body.gap.since} not delivered — re-sending now.`;
284
+ }
285
+ return { content: [{ type: "text", text: msg }] };
282
286
  }
283
287
  return { content: [{ type: "text", text: `Failed to connect: ${body.error ?? "unknown error"}` }] };
284
288
  } catch {
@@ -302,12 +306,23 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
302
306
 
303
307
  if (name === "polaris_status") {
304
308
  try {
305
- const res = await daemonGet(`/status/${CC_SESSION_ID}`);
306
- const body = (await res.json()) as { connected: boolean; project?: string; session?: string; user?: string; account?: string; slackChannel?: string };
309
+ const [statusRes, checkpointRes] = await Promise.all([
310
+ daemonGet(`/status/${CC_SESSION_ID}`),
311
+ daemonGet(`/checkpoint/${CC_SESSION_ID}`),
312
+ ]);
313
+ const body = (await statusRes.json()) as { connected: boolean; project?: string; session?: string; user?: string; account?: string; slackChannel?: string };
307
314
  if (body.connected) {
308
315
  const accountTag = body.account ? ` [${body.account}]` : "";
309
316
  const slackTag = body.slackChannel ? ` #${body.slackChannel}` : "";
310
- return { content: [{ type: "text", text: `Connected: ${body.project}/${body.session} as ${body.user}${accountTag}${slackTag}` }] };
317
+ let text = `Connected: ${body.project}/${body.session} as ${body.user}${accountTag}${slackTag}`;
318
+ try {
319
+ const cp = await checkpointRes.json() as { hasCheckpoint: boolean; lastEventTimestamp?: string; pending?: number };
320
+ if (cp.hasCheckpoint && cp.lastEventTimestamp) {
321
+ const ts = new Date(cp.lastEventTimestamp).toISOString().slice(11, 19) + "Z";
322
+ text += `\nLast delivered: ${ts}, ${cp.pending ?? 0} pending`;
323
+ }
324
+ } catch { /* best-effort */ }
325
+ return { content: [{ type: "text", text }] };
311
326
  }
312
327
  return { content: [{ type: "text", text: "Not connected to any Polaris session." }] };
313
328
  } catch {
@@ -478,7 +493,7 @@ async function main() {
478
493
  user: saved.user,
479
494
  profile: reconnectProfile,
480
495
  });
481
- const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; error?: string };
496
+ const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; error?: string; gap?: { events: number; since: string } | null };
482
497
  if (res.ok) {
483
498
  currentProject = body.project ?? saved.project;
484
499
  currentSession = body.session ?? saved.session;
@@ -486,7 +501,11 @@ async function main() {
486
501
  currentProfile = reconnectProfile;
487
502
  // Show email when it looks like one; fall back to profile key or "default"
488
503
  const accountLabel = reconnectProfile.includes("@") ? reconnectProfile : (reconnectProfile || "default");
489
- console.error(`Polaris auto-reconnected to #${currentProject}/${currentSession} [${accountLabel}]`);
504
+ let reconnMsg = `Polaris auto-reconnected to #${currentProject}/${currentSession} [${accountLabel}]`;
505
+ if (body.gap && body.gap.events > 0) {
506
+ reconnMsg += `; re-sending ${body.gap.events} events since ${body.gap.since}`;
507
+ }
508
+ console.error(reconnMsg);
490
509
  } else {
491
510
  // Session or project no longer exists — clear stale state
492
511
  await clearSessionState();
@@ -514,7 +533,12 @@ async function main() {
514
533
  profile: currentProfile,
515
534
  });
516
535
  if (reconnectRes.ok) {
517
- console.error(`Polaris: session dropped; auto-reconnected to #${currentProject}/${currentSession}`);
536
+ const reconnBody = await reconnectRes.json() as { gap?: { events: number; since: string } | null };
537
+ let reconnMsg = `Polaris: session dropped; auto-reconnected to #${currentProject}/${currentSession}`;
538
+ if (reconnBody.gap && reconnBody.gap.events > 0) {
539
+ reconnMsg += `; re-sending ${reconnBody.gap.events} events`;
540
+ }
541
+ console.error(reconnMsg);
518
542
  } else {
519
543
  console.error(`Polaris: session dropped and reconnect failed. Run /polaris to check status.`);
520
544
  currentProject = "";
@@ -2,6 +2,7 @@ import type { Server, ServerWebSocket } from "bun";
2
2
  import { readFile, writeFile, appendFile, mkdir, readdir, unlink } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { createHash } from "node:crypto";
5
6
 
6
7
  // --- Session registry ---
7
8
 
@@ -205,10 +206,13 @@ function ensureLogDir(): Promise<void> {
205
206
  return logReady;
206
207
  }
207
208
 
208
- async function logEvent(endpoint: string, payload: unknown, response?: { status: number; body?: unknown }): Promise<void> {
209
+ async function logEvent(endpoint: string, payload: unknown, response?: { status: number; body?: unknown }, context?: { project?: string; session?: string; ccSessionId?: string }): Promise<void> {
209
210
  try {
210
211
  await ensureLogDir();
211
212
  const entry: Record<string, unknown> = { t: new Date().toISOString(), endpoint, payload };
213
+ if (context?.project) entry.project = context.project;
214
+ if (context?.session) entry.session = context.session;
215
+ if (context?.ccSessionId) entry.ccSessionId = context.ccSessionId;
212
216
  if (response) entry.response = response;
213
217
  const file = join(LOG_DIR, `daemon-${new Date().toISOString().slice(0, 10)}.jsonl`);
214
218
  await appendFile(file, JSON.stringify(entry) + "\n");
@@ -324,6 +328,43 @@ async function logSessionChange(action: "connect" | "disconnect" | "ws-drop", cc
324
328
  } catch { /* best-effort */ }
325
329
  }
326
330
 
331
+ // --- Delivery checkpoint ---
332
+ //
333
+ // After each successful event relay, the daemon writes a tiny checkpoint file
334
+ // next to the session state file: ~/.polaris/sessions/{hash}-checkpoint.json
335
+ // where {hash} is the same SHA-256 prefix used by the client. On reconnect,
336
+ // the checkpoint is used to auto-backfill the gap without any manual action.
337
+
338
+ const SESSIONS_DIR = join(homedir(), ".polaris", "sessions");
339
+
340
+ function checkpointPath(ccSessionId: string): string {
341
+ const hash = createHash("sha256").update(ccSessionId).digest("hex").slice(0, 16);
342
+ return join(SESSIONS_DIR, `${hash}-checkpoint.json`);
343
+ }
344
+
345
+ interface Checkpoint {
346
+ lastEventId: string;
347
+ lastEventTimestamp: string;
348
+ project: string;
349
+ session: string;
350
+ }
351
+
352
+ async function writeCheckpoint(ccSessionId: string, eventId: string, eventTimestamp: string, project: string, session: string): Promise<void> {
353
+ try {
354
+ await mkdir(SESSIONS_DIR, { recursive: true });
355
+ const cp: Checkpoint = { lastEventId: eventId, lastEventTimestamp: eventTimestamp, project, session };
356
+ await writeFile(checkpointPath(ccSessionId), JSON.stringify(cp));
357
+ } catch { /* best-effort */ }
358
+ }
359
+
360
+ async function readCheckpoint(ccSessionId: string): Promise<Checkpoint | null> {
361
+ try {
362
+ const data = JSON.parse(await readFile(checkpointPath(ccSessionId), "utf-8")) as Checkpoint;
363
+ if (data.lastEventId && data.lastEventTimestamp) return data;
364
+ } catch { /* none */ }
365
+ return null;
366
+ }
367
+
327
368
  // --- Backfill: recover lost events from daemon log ---
328
369
 
329
370
  async function backfill(mapping: SessionMapping, duration?: string, from?: string, dryRun = false): Promise<{ recovered: number; would_recover?: number; source: string; gaps: string[] }> {
@@ -361,8 +402,18 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
361
402
  }
362
403
  }
363
404
 
405
+ // Collect all CC session IDs that belong to this mapping (MCP UUID + any hook alias).
406
+ // Both the MCP client's UUID and the CC hook session_id (if they differ) must match.
407
+ const knownCcIds = new Set<string>();
408
+ knownCcIds.add(mapping.ccSessionId);
409
+ for (const [id, m] of sessions) {
410
+ if (m.project === mapping.project && m.session === mapping.session) {
411
+ knownCcIds.add(id);
412
+ }
413
+ }
414
+
364
415
  // Read daemon log files covering the time range
365
- const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown> }> = [];
416
+ const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown>; project?: string; session?: string; ccSessionId?: string; response?: unknown }> = [];
366
417
  const startDate = since.toISOString().slice(0, 10);
367
418
  const endDate = now.toISOString().slice(0, 10);
368
419
 
@@ -385,6 +436,16 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
385
436
  if (entry.endpoint !== "/events") continue;
386
437
  if (new Date(entry.t) <= since) continue;
387
438
  if (new Date(entry.t) > now) continue;
439
+ // Skip error-response entries — they're duplicates of the primary entry
440
+ // and would cause the same event to be replayed twice.
441
+ if (entry.response !== undefined) continue;
442
+ // Filter by origin. New entries (v0.0.105+) have explicit context fields;
443
+ // old entries can be matched via payload.session_id. Both must belong to
444
+ // this mapping's known CC session IDs to prevent cross-session pollution.
445
+ const entrySessionId: string | undefined = entry.ccSessionId ?? entry.payload.session_id;
446
+ if (entrySessionId !== undefined && !knownCcIds.has(entrySessionId)) continue;
447
+ if (entry.project !== undefined && entry.project !== mapping.project) continue;
448
+ if (entry.session !== undefined && entry.session !== mapping.session) continue;
388
449
  logEntries.push(entry);
389
450
  } catch { /* skip malformed lines */ }
390
451
  }
@@ -424,6 +485,19 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
424
485
 
425
486
  return { recovered, source: "daemon_log", gaps: [] };
426
487
  }
488
+
489
+ // Auto-backfill from checkpoint after a reconnect. Called non-blocking (void)
490
+ // so the /connect response is returned immediately while replay runs in background.
491
+ async function autoBackfillAfterConnect(mapping: SessionMapping): Promise<void> {
492
+ const cp = await readCheckpoint(mapping.ccSessionId);
493
+ if (!cp) return;
494
+ if (cp.project !== mapping.project || cp.session !== mapping.session) return;
495
+ const result = await backfill(mapping, undefined, cp.lastEventTimestamp);
496
+ if (result.recovered > 0) {
497
+ console.error(`polaris daemon: gap detected — auto-backfilled ${result.recovered} events since ${cp.lastEventTimestamp}`);
498
+ }
499
+ }
500
+
427
501
  // --- HTTP Server ---
428
502
 
429
503
  function json(data: unknown, status = 200): Response {
@@ -552,6 +626,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
552
626
  // Retry with new name on 409 (collision with generated name)
553
627
  let attempts = 0;
554
628
  let created = false;
629
+ let isReconnect = false; // true only when the session already existed (409 + explicit name)
555
630
  while (!created && attempts < 3) {
556
631
  const sessionRes = await fetch(`${serviceUrl}/projects/${body.project}/sessions`, {
557
632
  method: "POST",
@@ -569,6 +644,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
569
644
  body: JSON.stringify({ driver: body.user }),
570
645
  });
571
646
  created = true;
647
+ isReconnect = true; // session pre-existed → this is a true reconnect
572
648
  } else {
573
649
  // Generated name collision — retry with new name
574
650
  mapping.session = generateSessionName();
@@ -597,12 +673,33 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
597
673
  connectCloudWs(mapping);
598
674
  await logSessionChange("connect", mapping.ccSessionId, mapping.project, mapping.session, mapping.user, mapping.agent);
599
675
 
676
+ // Check for a delivery gap since the last checkpoint (local-only dry run — no network).
677
+ // Only meaningful on a true reconnect (existing session); a brand-new session has no
678
+ // history to recover. Clear any stale checkpoint when starting a fresh session.
679
+ let gap: { events: number; since: string } | null = null;
680
+ if (isReconnect) {
681
+ const cp = await readCheckpoint(mapping.ccSessionId);
682
+ if (cp && cp.project === mapping.project && cp.session === mapping.session) {
683
+ const dryResult = await backfill(mapping, undefined, cp.lastEventTimestamp, true);
684
+ const count = dryResult.would_recover ?? 0;
685
+ if (count > 0) {
686
+ gap = { events: count, since: cp.lastEventTimestamp };
687
+ void autoBackfillAfterConnect(mapping);
688
+ }
689
+ }
690
+ } else {
691
+ // Fresh session — clear any checkpoint left over from a previous session
692
+ // on this CC session ID so stale data never triggers a false backfill.
693
+ void unlink(checkpointPath(mapping.ccSessionId)).catch(() => {});
694
+ }
695
+
600
696
  return json({
601
697
  status: "connected",
602
698
  project: mapping.project,
603
699
  session: mapping.session,
604
700
  user: mapping.user,
605
701
  agent: mapping.agent,
702
+ gap,
606
703
  });
607
704
  } catch {
608
705
  return error("Invalid JSON", 400);
@@ -623,6 +720,8 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
623
720
  mapping.session = "";
624
721
  mapping.user = "";
625
722
  }
723
+ // Clear checkpoint — a deliberate disconnect means there is nothing to recover
724
+ void unlink(checkpointPath(body.ccSessionId)).catch(() => {});
626
725
  return json({ status: "disconnected" });
627
726
  } catch {
628
727
  return error("Invalid JSON", 400);
@@ -636,6 +735,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
636
735
  mapping.project = "";
637
736
  mapping.session = "";
638
737
  mapping.user = "";
738
+ void unlink(checkpointPath(id)).catch(() => {});
639
739
  }
640
740
  sessions.clear();
641
741
  injectQueues.clear();
@@ -646,7 +746,6 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
646
746
  if (method === "POST" && pathname === "/events") {
647
747
  try {
648
748
  const body = (await req.json()) as { session_id?: string; [key: string]: unknown };
649
- await logEvent("/events", body);
650
749
  const ccSessionId = body.session_id;
651
750
  if (!ccSessionId) return error("session_id required in hook payload", 400);
652
751
 
@@ -689,6 +788,9 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
689
788
  }
690
789
  }
691
790
 
791
+ // Log with session context so backfill can filter by exact origin
792
+ await logEvent("/events", body, undefined, { project: mapping.project, session: mapping.session, ccSessionId });
793
+
692
794
  // Determine sender: human for prompts, agent for everything else
693
795
  const hookEvent = body.hook_event_name as string | undefined;
694
796
  const sender = hookEvent === "UserPromptSubmit" ? mapping.user : mapping.agent;
@@ -713,7 +815,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
713
815
 
714
816
  if (!res || res.status >= 500) {
715
817
  console.error(`polaris daemon: upstream relay failed (${res ? res.status : "network error"}) — queued to outbox`);
716
- await logEvent("/events", body, { status: res?.status ?? 0, body: "queued to outbox" });
818
+ await logEvent("/events", body, { status: res?.status ?? 0, body: "queued to outbox" }, { project: mapping.project, session: mapping.session, ccSessionId });
717
819
  await enqueueOutbox(mapping.project, mapping.session, mapping.profile, relayBody);
718
820
  // The event is durably accepted (outbox), so still drain injects
719
821
  if (hookEvent === "UserPromptSubmit") {
@@ -726,10 +828,18 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
726
828
 
727
829
  if (!res.ok) {
728
830
  const err = await res.text();
729
- await logEvent("/events", body, { status: res.status, body: err });
831
+ await logEvent("/events", body, { status: res.status, body: err }, { project: mapping.project, session: mapping.session, ccSessionId });
730
832
  return new Response(err, { status: res.status });
731
833
  }
732
834
 
835
+ // Write delivery checkpoint so reconnects can auto-backfill the exact gap
836
+ try {
837
+ const delivered = await res.json() as { id?: string; timestamp?: string };
838
+ if (delivered.id && delivered.timestamp) {
839
+ void writeCheckpoint(ccSessionId, delivered.id, delivered.timestamp, mapping.project, mapping.session);
840
+ }
841
+ } catch { /* best-effort — don't block the response */ }
842
+
733
843
  // UserPromptSubmit: drain queued advisor injects and hand them back
734
844
  // to the hook, which surfaces them via additionalContext
735
845
  if (hookEvent === "UserPromptSubmit") {
@@ -967,6 +1077,20 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
967
1077
  }
968
1078
  }
969
1079
 
1080
+ // GET /checkpoint/:ccSessionId — checkpoint state + pending count for polaris_status
1081
+ if (method === "GET" && pathname.startsWith("/checkpoint/")) {
1082
+ const ccSessionId = pathname.slice("/checkpoint/".length);
1083
+ const cp = await readCheckpoint(ccSessionId);
1084
+ if (!cp) return json({ hasCheckpoint: false });
1085
+ const mapping = sessions.get(ccSessionId);
1086
+ let pending = 0;
1087
+ if (mapping?.project && cp.project === mapping.project && cp.session === mapping.session) {
1088
+ const dryResult = await backfill(mapping, undefined, cp.lastEventTimestamp, true);
1089
+ pending = dryResult.would_recover ?? 0;
1090
+ }
1091
+ return json({ hasCheckpoint: true, lastEventTimestamp: cp.lastEventTimestamp, pending });
1092
+ }
1093
+
970
1094
  // POST /backfill — recover lost events from daemon log
971
1095
  if (method === "POST" && pathname === "/backfill") {
972
1096
  try {
package/tests/e2e.test.ts CHANGED
@@ -32,6 +32,8 @@ afterAll(async () => {
32
32
 
33
33
  beforeEach(async () => {
34
34
  await resetTestData(sql);
35
+ // Disconnect all daemon sessions so checkpoint files are cleared between tests
36
+ await fetch(`${daemonUrl}/disconnect-all`, { method: "POST", headers: { "Content-Type": "application/json" } });
35
37
  });
36
38
 
37
39
  async function post(base: string, path: string, body: unknown) {