@lightupai/polaris 0.0.106 → 0.0.110

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.106",
3
+ "version": "0.0.110",
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
@@ -275,7 +275,52 @@ async function removeSystemdService(): Promise<void> {
275
275
  }
276
276
 
277
277
  // Platform dispatcher
278
+ // --- Session state migration ---
279
+ //
280
+ // When upgrading from a pre-0.0.109 daemon (which never wrote daemon-state.json),
281
+ // the new daemon would start with an empty session map and drop every connected
282
+ // session. This function fetches the running daemon's session list via GET /status
283
+ // and writes daemon-state.json before the daemon is stopped, so the new daemon
284
+ // can restore the map and reconnect cloud WebSockets without any user action.
285
+ // Best-effort: failures are silent (daemon may not be running, or already migrated).
286
+ async function migrateSessionState(): Promise<void> {
287
+ const stateFile = join(POLARIS_DIR, "sessions", "daemon-state.json");
288
+ // If a 0.0.109+ daemon already wrote the file, its state is more complete
289
+ // (includes learned hook aliases) — don't overwrite it.
290
+ try { await readFile(stateFile); return; } catch { /* doesn't exist — proceed */ }
291
+
292
+ try {
293
+ const config = await loadConfig();
294
+ const secret = resolveDaemonSecret(config);
295
+ const headers: Record<string, string> = {};
296
+ if (secret) headers["x-polaris-daemon-secret"] = secret;
297
+ const res = await fetch("http://127.0.0.1:4322/status", {
298
+ headers,
299
+ signal: AbortSignal.timeout(2000),
300
+ });
301
+ if (!res.ok) return;
302
+ const data = await res.json() as { sessions?: Array<{ ccSessionId: string; project: string; session: string; user: string }> };
303
+ if (!data.sessions || data.sessions.length === 0) return;
304
+
305
+ const activeProfile = config?.active || "";
306
+ const sessions = data.sessions.map((s) => ({
307
+ ccSessionId: s.ccSessionId,
308
+ project: s.project,
309
+ session: s.session,
310
+ user: s.user,
311
+ agent: "agent:claude",
312
+ profile: activeProfile,
313
+ }));
314
+
315
+ await mkdir(join(POLARIS_DIR, "sessions"), { recursive: true });
316
+ await writeFile(stateFile, JSON.stringify({ sessions }));
317
+ console.log(` ✓ Migrated ${sessions.length} active session(s) to persistent state`);
318
+ } catch { /* best-effort — daemon not running or unreachable */ }
319
+ }
320
+
278
321
  async function installDaemonService(daemonSecret?: string): Promise<void> {
322
+ // Migrate session state from older daemon before it is stopped
323
+ await migrateSessionState();
279
324
  if (process.platform === "darwin") return installLaunchAgent(daemonSecret);
280
325
  if (process.platform === "linux") return installSystemdService(daemonSecret);
281
326
  // Windows and others: no managed service; daemon is started manually
@@ -428,8 +473,8 @@ async function install(participantId?: string) {
428
473
  const skillContent = `---
429
474
  name: polaris
430
475
  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)]
476
+ allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session polaris_update
477
+ argument-hint: [join #channel [session-name] | rename <new-name> | rename-session <name> | disconnect | update | (no args for status)]
433
478
  ---
434
479
 
435
480
  ## Polaris — Multiplayer Collaboration
@@ -453,6 +498,10 @@ Based on the arguments provided, do ONE of the following:
453
498
  1. Call \`polaris_rename_session\` with the new name
454
499
  2. Report the result
455
500
 
501
+ **\`/polaris update\`** — Update Polaris to the latest version:
502
+ 1. Call \`polaris_update\`
503
+ 2. Report what changed and remind the user to restart Claude Code if the MCP server was updated
504
+
456
505
  **\`/polaris disconnect\`** — Disconnect:
457
506
  1. Call \`polaris_disconnect\`
458
507
  2. Confirm disconnection
@@ -571,8 +620,8 @@ async function login(appUrl: string, profileName?: string, loginHint?: string) {
571
620
  const skillContent = `---
572
621
  name: polaris
573
622
  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)]
623
+ allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_rename_session polaris_update
624
+ argument-hint: [join #channel [session-name] | rename <new-name> | rename-session <name> | disconnect | update | (no args for status)]
576
625
  ---
577
626
 
578
627
  ## Polaris — Multiplayer Collaboration
@@ -596,6 +645,10 @@ Based on the arguments provided, do ONE of the following:
596
645
  1. Call \`polaris_rename_session\` with the new name
597
646
  2. Report the result
598
647
 
648
+ **\`/polaris update\`** — Update Polaris to the latest version:
649
+ 1. Call \`polaris_update\`
650
+ 2. Report what changed and remind the user to restart Claude Code if the MCP server was updated
651
+
599
652
  **\`/polaris disconnect\`** — Disconnect:
600
653
  1. Call \`polaris_disconnect\`
601
654
  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,49 @@ 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 the copy that's actually in use: ~/.polaris/mcp (no sudo required).
499
+ // This is the same location the version was read from, and where the daemon
500
+ // and MCP server binaries are resolved by run-daemon.sh and claude mcp add.
501
+ const mcpDir = `${homedir()}/.polaris/mcp`;
502
+ const npmResult = Bun.spawnSync(
503
+ ["npm", "install", "--prefix", mcpDir, `@lightupai/polaris@${latestVersion}`],
504
+ { stdout: "pipe", stderr: "pipe" }
505
+ );
506
+ if (npmResult.exitCode !== 0) {
507
+ return { content: [{ type: "text", text: `Update failed:\n${npmResult.stderr.toString().trim()}` }] };
508
+ }
509
+
510
+ // Refresh hooks, migrate session state, and restart the daemon using the
511
+ // local polaris binary (not relying on PATH, which may not include it).
512
+ const polarisBin = `${mcpDir}/node_modules/.bin/polaris`;
513
+ const installResult = Bun.spawnSync([polarisBin, "install"], { stdout: "pipe", stderr: "pipe" });
514
+ const msg = installResult.exitCode === 0
515
+ ? `Updated Polaris from v${currentVersion} to v${latestVersion}.\n\nRestart Claude Code for the new MCP server to take effect.`
516
+ : `Updated package to v${latestVersion}, but \`polaris install\` failed — run it manually.\n${installResult.stderr.toString().trim()}`;
517
+ return { content: [{ type: "text", text: msg }] };
518
+ }
519
+
469
520
  throw new Error(`Unknown tool: ${name}`);
470
521
  });
471
522
 
@@ -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`);
@@ -693,6 +778,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
693
778
  void unlink(checkpointPath(mapping.ccSessionId)).catch(() => {});
694
779
  }
695
780
 
781
+ void persistSessions();
696
782
  return json({
697
783
  status: "connected",
698
784
  project: mapping.project,
@@ -722,6 +808,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
722
808
  }
723
809
  // Clear checkpoint — a deliberate disconnect means there is nothing to recover
724
810
  void unlink(checkpointPath(body.ccSessionId)).catch(() => {});
811
+ void persistSessions();
725
812
  return json({ status: "disconnected" });
726
813
  } catch {
727
814
  return error("Invalid JSON", 400);
@@ -739,6 +826,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
739
826
  }
740
827
  sessions.clear();
741
828
  injectQueues.clear();
829
+ void persistSessions();
742
830
  return json({ status: "all_disconnected" });
743
831
  }
744
832
 
@@ -773,6 +861,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
773
861
  mapping.pendingMapping = false;
774
862
  // Register under the real CC session ID for future events
775
863
  sessions.set(ccSessionId, { ...mapping, ccSessionId });
864
+ void persistSessions();
776
865
  console.error(`[daemon] Mapped CC session ${ccSessionId.slice(0, 8)} → ${mapping.project}/${mapping.session}`);
777
866
  // Share the inject queue between the original ccSessionId (where the
778
867
  // cloud WS enqueues) and this CC hook session_id alias, so injected
@@ -880,7 +969,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
880
969
  m.slackChannel = body.newName;
881
970
  }
882
971
  }
883
-
972
+ void persistSessions();
884
973
  return json({ status: "renamed", oldName: body.oldName, newName: body.newName });
885
974
  } catch {
886
975
  return error("Invalid JSON", 400);
@@ -911,6 +1000,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
911
1000
 
912
1001
  const oldName = mapping.session;
913
1002
  mapping.session = body.newName;
1003
+ void persistSessions();
914
1004
  return json({ status: "renamed", oldName, newName: body.newName });
915
1005
  } catch {
916
1006
  return error("Invalid JSON", 400);
@@ -928,6 +1018,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
928
1018
  m.slackChannel = body.slackChannel;
929
1019
  }
930
1020
  }
1021
+ void persistSessions();
931
1022
  return json({ status: "updated" });
932
1023
  } catch {
933
1024
  return error("Invalid JSON", 400);
@@ -1135,6 +1226,11 @@ if (import.meta.main) {
1135
1226
  // Load config before starting so getServiceUrl() has the active profile
1136
1227
  // (and getDaemonSecret() the configured daemonSecret)
1137
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();
1138
1234
  // Replay any events left in the outbox by a previous daemon run
1139
1235
  await scanOutbox();
1140
1236
  const { server } = startDaemon();