@lightupai/polaris 0.0.105 → 0.0.109

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.
@@ -24,6 +24,44 @@ if [ -n "$CC_SESSION_ID" ]; then
24
24
  CONNECTED=$(echo "$STATUS" | jq -r '.connected' 2>/dev/null || echo "false")
25
25
  fi
26
26
 
27
+ # --- Update check (cached, non-blocking) ---
28
+ # Reads installed version from the MCP package, caches the latest npm version
29
+ # for up to 1 hour. Background curl refresh never blocks the status line.
30
+ UPDATE_MSG=""
31
+ INSTALLED_PKG="$HOME/.polaris/mcp/node_modules/@lightupai/polaris/package.json"
32
+ UPDATE_CACHE="$HOME/.polaris/update-check.json"
33
+
34
+ if [ -f "$INSTALLED_PKG" ]; then
35
+ INSTALLED_VERSION=$(jq -r '.version // ""' "$INSTALLED_PKG" 2>/dev/null || echo "")
36
+
37
+ CACHED_LATEST=""
38
+ if [ -f "$UPDATE_CACHE" ]; then
39
+ CACHED_LATEST=$(jq -r '.latest // ""' "$UPDATE_CACHE" 2>/dev/null || echo "")
40
+ CACHE_TS=$(jq -r '.checked // 0' "$UPDATE_CACHE" 2>/dev/null || echo "0")
41
+ NOW=$(date +%s 2>/dev/null || echo "0")
42
+ CACHE_AGE=$(( NOW - CACHE_TS )) 2>/dev/null || CACHE_AGE=9999
43
+ STALE=false
44
+ [ "$CACHE_AGE" -gt 3600 ] 2>/dev/null && STALE=true
45
+ else
46
+ STALE=true
47
+ fi
48
+
49
+ if [ "$STALE" = "true" ]; then
50
+ # Refresh in background — never blocks the status line
51
+ (
52
+ LATEST_V=$(curl -sf --max-time 5 "https://registry.npmjs.org/@lightupai/polaris/latest" 2>/dev/null \
53
+ | jq -r '.version // ""' 2>/dev/null)
54
+ if [ -n "$LATEST_V" ]; then
55
+ printf '{"latest":"%s","checked":%s}\n' "$LATEST_V" "$(date +%s)" > "$UPDATE_CACHE"
56
+ fi
57
+ ) &
58
+ fi
59
+
60
+ if [ -n "$CACHED_LATEST" ] && [ -n "$INSTALLED_VERSION" ] && [ "$CACHED_LATEST" != "$INSTALLED_VERSION" ]; then
61
+ UPDATE_MSG=" [update available: v${CACHED_LATEST}]"
62
+ fi
63
+ fi
64
+
27
65
  if [ "$CONNECTED" = "true" ]; then
28
66
  PROJECT=$(echo "$STATUS" | jq -r '.project' 2>/dev/null)
29
67
  SESSION=$(echo "$STATUS" | jq -r '.session' 2>/dev/null)
@@ -35,10 +73,10 @@ if [ "$CONNECTED" = "true" ]; then
35
73
  ACCOUNT_TAG="[${ACCOUNT}] "
36
74
  fi
37
75
  if [ -n "$SLACK" ]; then
38
- echo "polaris: ${ACCOUNT_TAG}${PROJECT}/${SESSION} (${USER}) #${SLACK}"
76
+ echo "polaris: ${ACCOUNT_TAG}${PROJECT}/${SESSION} (${USER}) #${SLACK}${UPDATE_MSG}"
39
77
  else
40
- echo "polaris: ${ACCOUNT_TAG}${PROJECT}/${SESSION} (${USER})"
78
+ echo "polaris: ${ACCOUNT_TAG}${PROJECT}/${SESSION} (${USER})${UPDATE_MSG}"
41
79
  fi
42
80
  else
43
- echo "polaris: not connected"
81
+ echo "polaris: not connected${UPDATE_MSG}"
44
82
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.105",
3
+ "version": "0.0.109",
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",
package/src/cli/cli.ts CHANGED
@@ -428,8 +428,8 @@ async function install(participantId?: string) {
428
428
  const skillContent = `---
429
429
  name: polaris
430
430
  description: Connect to a Polaris multiplayer collaboration session
431
- allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session
432
- argument-hint: [join #channel [session-name] | rename <new-name> | rename-session <name> | disconnect | (no args for status)]
431
+ allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session polaris_update
432
+ argument-hint: [join #channel [session-name] | rename <new-name> | rename-session <name> | disconnect | update | (no args for status)]
433
433
  ---
434
434
 
435
435
  ## Polaris — Multiplayer Collaboration
@@ -453,6 +453,10 @@ Based on the arguments provided, do ONE of the following:
453
453
  1. Call \`polaris_rename_session\` with the new name
454
454
  2. Report the result
455
455
 
456
+ **\`/polaris update\`** — Update Polaris to the latest version:
457
+ 1. Call \`polaris_update\`
458
+ 2. Report what changed and remind the user to restart Claude Code if the MCP server was updated
459
+
456
460
  **\`/polaris disconnect\`** — Disconnect:
457
461
  1. Call \`polaris_disconnect\`
458
462
  2. Confirm disconnection
@@ -571,8 +575,8 @@ async function login(appUrl: string, profileName?: string, loginHint?: string) {
571
575
  const skillContent = `---
572
576
  name: polaris
573
577
  description: Connect to a Polaris multiplayer collaboration session
574
- allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session
575
- argument-hint: [join #channel [session-name] | rename <new-name> | rename-session <name> | disconnect | (no args for status)]
578
+ allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session polaris_update
579
+ argument-hint: [join #channel [session-name] | rename <new-name> | rename-session <name> | disconnect | update | (no args for status)]
576
580
  ---
577
581
 
578
582
  ## Polaris — Multiplayer Collaboration
@@ -596,6 +600,10 @@ Based on the arguments provided, do ONE of the following:
596
600
  1. Call \`polaris_rename_session\` with the new name
597
601
  2. Report the result
598
602
 
603
+ **\`/polaris update\`** — Update Polaris to the latest version:
604
+ 1. Call \`polaris_update\`
605
+ 2. Report what changed and remind the user to restart Claude Code if the MCP server was updated
606
+
599
607
  **\`/polaris disconnect\`** — Disconnect:
600
608
  1. Call \`polaris_disconnect\`
601
609
  2. Confirm disconnection
@@ -195,6 +195,14 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
195
195
  properties: {},
196
196
  },
197
197
  },
198
+ {
199
+ name: "polaris_update",
200
+ description: "Update Polaris to the latest version. Runs npm install -g then polaris install to refresh hooks and the MCP server.",
201
+ inputSchema: {
202
+ type: "object" as const,
203
+ properties: {},
204
+ },
205
+ },
198
206
  {
199
207
  name: "polaris_backfill",
200
208
  description: "Recover lost events from local daemon logs. Use when events were lost due to disconnection or API downtime.",
@@ -466,6 +474,41 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
466
474
  }
467
475
  }
468
476
 
477
+ if (name === "polaris_update") {
478
+ const installedPkgPath = `${homedir()}/.polaris/mcp/node_modules/@lightupai/polaris/package.json`;
479
+ let currentVersion = "";
480
+ try {
481
+ const pkg = JSON.parse(await readFile(installedPkgPath, "utf-8")) as { version: string };
482
+ currentVersion = pkg.version ?? "";
483
+ } catch { /* package not installed yet */ }
484
+
485
+ let latestVersion = "";
486
+ try {
487
+ const res = await fetch("https://registry.npmjs.org/@lightupai/polaris/latest", { signal: AbortSignal.timeout(5000) });
488
+ if (res.ok) ({ version: latestVersion } = await res.json() as { version: string });
489
+ } catch { /* network unavailable */ }
490
+
491
+ if (!latestVersion) {
492
+ return { content: [{ type: "text", text: "Could not reach npm to check for updates. Check your network connection." }] };
493
+ }
494
+ if (latestVersion === currentVersion) {
495
+ return { content: [{ type: "text", text: `Polaris is already up to date (v${currentVersion}).` }] };
496
+ }
497
+
498
+ // Update global binary
499
+ const npmResult = Bun.spawnSync(["npm", "install", "-g", "@lightupai/polaris@latest"], { stdout: "pipe", stderr: "pipe" });
500
+ if (npmResult.exitCode !== 0) {
501
+ return { content: [{ type: "text", text: `Update failed:\n${npmResult.stderr.toString().trim()}` }] };
502
+ }
503
+
504
+ // Refresh hooks, MCP package, and daemon registration
505
+ const installResult = Bun.spawnSync(["polaris", "install"], { stdout: "pipe", stderr: "pipe" });
506
+ const msg = installResult.exitCode === 0
507
+ ? `Updated Polaris from v${currentVersion} to v${latestVersion}.\n\nRestart Claude Code for the new MCP server to take effect.`
508
+ : `Updated global binary to v${latestVersion}, but \`polaris install\` failed — run it manually.\n${installResult.stderr.toString().trim()}`;
509
+ return { content: [{ type: "text", text: msg }] };
510
+ }
511
+
469
512
  throw new Error(`Unknown tool: ${name}`);
470
513
  });
471
514
 
@@ -328,6 +328,93 @@ async function logSessionChange(action: "connect" | "disconnect" | "ws-drop", cc
328
328
  } catch { /* best-effort */ }
329
329
  }
330
330
 
331
+ // --- Session persistence ---
332
+ //
333
+ // The daemon writes its session map to ~/.polaris/sessions/daemon-state.json
334
+ // after every mutation, and reloads it on startup. This makes daemon restarts
335
+ // (e.g. during `polaris update`) invisible to connected sessions: the new daemon
336
+ // loads the map and re-opens cloud WebSocket connections before serving requests.
337
+ //
338
+ // Persistence is only active when the daemon is run as main (import.meta.main).
339
+ // Tests that call startDaemon() in-process never enable it, so developer state
340
+ // is never overwritten by the test suite.
341
+
342
+ const SESSIONS_DIR = join(homedir(), ".polaris", "sessions");
343
+ const DAEMON_STATE_FILE = join(SESSIONS_DIR, "daemon-state.json");
344
+ let persistenceEnabled = false;
345
+
346
+ interface PersistedSession {
347
+ ccSessionId: string;
348
+ project: string;
349
+ session: string;
350
+ user: string;
351
+ agent: string;
352
+ profile: string;
353
+ slackChannel?: string;
354
+ }
355
+
356
+ async function persistSessions(): Promise<void> {
357
+ if (!persistenceEnabled) return;
358
+ try {
359
+ await mkdir(SESSIONS_DIR, { recursive: true });
360
+ const active: PersistedSession[] = [];
361
+ for (const m of sessions.values()) {
362
+ if (!m.project) continue; // skip empty stubs from /register
363
+ active.push({
364
+ ccSessionId: m.ccSessionId,
365
+ project: m.project,
366
+ session: m.session,
367
+ user: m.user,
368
+ agent: m.agent,
369
+ profile: m.profile,
370
+ ...(m.slackChannel ? { slackChannel: m.slackChannel } : {}),
371
+ });
372
+ }
373
+ await writeFile(DAEMON_STATE_FILE, JSON.stringify({ sessions: active }));
374
+ } catch { /* best-effort */ }
375
+ }
376
+
377
+ async function loadPersistedSessions(): Promise<void> {
378
+ try {
379
+ const data = JSON.parse(await readFile(DAEMON_STATE_FILE, "utf-8")) as { sessions: PersistedSession[] };
380
+ if (!Array.isArray(data.sessions)) return;
381
+
382
+ // Populate the sessions map
383
+ for (const s of data.sessions) {
384
+ if (!s.ccSessionId || !s.project || !s.session) continue;
385
+ sessions.set(s.ccSessionId, {
386
+ ccSessionId: s.ccSessionId,
387
+ project: s.project,
388
+ session: s.session,
389
+ user: s.user,
390
+ agent: s.agent,
391
+ profile: s.profile || "",
392
+ slackChannel: s.slackChannel,
393
+ ws: null,
394
+ pendingMapping: false, // aliases are already known — no claiming needed
395
+ });
396
+ }
397
+
398
+ // Open one cloud WebSocket per (project, session) pair; alias entries share it
399
+ const primaryForPair = new Map<string, SessionMapping>();
400
+ for (const mapping of sessions.values()) {
401
+ if (!mapping.project) continue;
402
+ const key = `${mapping.project}/${mapping.session}`;
403
+ if (!primaryForPair.has(key)) {
404
+ primaryForPair.set(key, mapping);
405
+ connectCloudWs(mapping);
406
+ } else {
407
+ mapping.ws = primaryForPair.get(key)!.ws;
408
+ }
409
+ }
410
+
411
+ const count = Array.from(sessions.values()).filter((m) => m.project).length;
412
+ if (count > 0) {
413
+ console.error(`polaris daemon: restored ${count} mapping(s) across ${primaryForPair.size} session(s)`);
414
+ }
415
+ } catch { /* no state file — fresh start */ }
416
+ }
417
+
331
418
  // --- Delivery checkpoint ---
332
419
  //
333
420
  // After each successful event relay, the daemon writes a tiny checkpoint file
@@ -335,8 +422,6 @@ async function logSessionChange(action: "connect" | "disconnect" | "ws-drop", cc
335
422
  // where {hash} is the same SHA-256 prefix used by the client. On reconnect,
336
423
  // the checkpoint is used to auto-backfill the gap without any manual action.
337
424
 
338
- const SESSIONS_DIR = join(homedir(), ".polaris", "sessions");
339
-
340
425
  function checkpointPath(ccSessionId: string): string {
341
426
  const hash = createHash("sha256").update(ccSessionId).digest("hex").slice(0, 16);
342
427
  return join(SESSIONS_DIR, `${hash}-checkpoint.json`);
@@ -402,6 +487,16 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
402
487
  }
403
488
  }
404
489
 
490
+ // Collect all CC session IDs that belong to this mapping (MCP UUID + any hook alias).
491
+ // Both the MCP client's UUID and the CC hook session_id (if they differ) must match.
492
+ const knownCcIds = new Set<string>();
493
+ knownCcIds.add(mapping.ccSessionId);
494
+ for (const [id, m] of sessions) {
495
+ if (m.project === mapping.project && m.session === mapping.session) {
496
+ knownCcIds.add(id);
497
+ }
498
+ }
499
+
405
500
  // Read daemon log files covering the time range
406
501
  const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown>; project?: string; session?: string; ccSessionId?: string; response?: unknown }> = [];
407
502
  const startDate = since.toISOString().slice(0, 10);
@@ -426,14 +521,16 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
426
521
  if (entry.endpoint !== "/events") continue;
427
522
  if (new Date(entry.t) <= since) continue;
428
523
  if (new Date(entry.t) > now) continue;
429
- // Filter by origin (added since v0.0.105). Old entries without these fields
430
- // pass through for backwards compatibility with pre-105 logs.
431
- if (entry.project !== undefined && entry.project !== mapping.project) continue;
432
- if (entry.session !== undefined && entry.session !== mapping.session) continue;
433
- if (entry.ccSessionId !== undefined && entry.ccSessionId !== mapping.ccSessionId) continue;
434
524
  // Skip error-response entries — they're duplicates of the primary entry
435
525
  // and would cause the same event to be replayed twice.
436
526
  if (entry.response !== undefined) continue;
527
+ // Filter by origin. New entries (v0.0.105+) have explicit context fields;
528
+ // old entries can be matched via payload.session_id. Both must belong to
529
+ // this mapping's known CC session IDs to prevent cross-session pollution.
530
+ const entrySessionId: string | undefined = entry.ccSessionId ?? entry.payload.session_id;
531
+ if (entrySessionId !== undefined && !knownCcIds.has(entrySessionId)) continue;
532
+ if (entry.project !== undefined && entry.project !== mapping.project) continue;
533
+ if (entry.session !== undefined && entry.session !== mapping.session) continue;
437
534
  logEntries.push(entry);
438
535
  } catch { /* skip malformed lines */ }
439
536
  }
@@ -681,6 +778,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
681
778
  void unlink(checkpointPath(mapping.ccSessionId)).catch(() => {});
682
779
  }
683
780
 
781
+ void persistSessions();
684
782
  return json({
685
783
  status: "connected",
686
784
  project: mapping.project,
@@ -710,6 +808,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
710
808
  }
711
809
  // Clear checkpoint — a deliberate disconnect means there is nothing to recover
712
810
  void unlink(checkpointPath(body.ccSessionId)).catch(() => {});
811
+ void persistSessions();
713
812
  return json({ status: "disconnected" });
714
813
  } catch {
715
814
  return error("Invalid JSON", 400);
@@ -727,6 +826,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
727
826
  }
728
827
  sessions.clear();
729
828
  injectQueues.clear();
829
+ void persistSessions();
730
830
  return json({ status: "all_disconnected" });
731
831
  }
732
832
 
@@ -761,6 +861,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
761
861
  mapping.pendingMapping = false;
762
862
  // Register under the real CC session ID for future events
763
863
  sessions.set(ccSessionId, { ...mapping, ccSessionId });
864
+ void persistSessions();
764
865
  console.error(`[daemon] Mapped CC session ${ccSessionId.slice(0, 8)} → ${mapping.project}/${mapping.session}`);
765
866
  // Share the inject queue between the original ccSessionId (where the
766
867
  // cloud WS enqueues) and this CC hook session_id alias, so injected
@@ -868,7 +969,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
868
969
  m.slackChannel = body.newName;
869
970
  }
870
971
  }
871
-
972
+ void persistSessions();
872
973
  return json({ status: "renamed", oldName: body.oldName, newName: body.newName });
873
974
  } catch {
874
975
  return error("Invalid JSON", 400);
@@ -899,6 +1000,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
899
1000
 
900
1001
  const oldName = mapping.session;
901
1002
  mapping.session = body.newName;
1003
+ void persistSessions();
902
1004
  return json({ status: "renamed", oldName, newName: body.newName });
903
1005
  } catch {
904
1006
  return error("Invalid JSON", 400);
@@ -916,6 +1018,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
916
1018
  m.slackChannel = body.slackChannel;
917
1019
  }
918
1020
  }
1021
+ void persistSessions();
919
1022
  return json({ status: "updated" });
920
1023
  } catch {
921
1024
  return error("Invalid JSON", 400);
@@ -1123,6 +1226,11 @@ if (import.meta.main) {
1123
1226
  // Load config before starting so getServiceUrl() has the active profile
1124
1227
  // (and getDaemonSecret() the configured daemonSecret)
1125
1228
  await loadConfig();
1229
+ // Enable session persistence (only when running as main, never in tests)
1230
+ persistenceEnabled = true;
1231
+ // Restore sessions from disk before opening the port — new daemon is ready
1232
+ // to serve hook events immediately, before clients notice the restart
1233
+ await loadPersistedSessions();
1126
1234
  // Replay any events left in the outbox by a previous daemon run
1127
1235
  await scanOutbox();
1128
1236
  const { server } = startDaemon();