@lightupai/polaris 0.0.100 → 0.0.102

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.100",
3
+ "version": "0.0.102",
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",
@@ -47,14 +47,18 @@ async function daemonGet(path: string): Promise<Response> {
47
47
  // --- Session state persistence ---
48
48
 
49
49
  function sessionStatePath(): string {
50
- const hash = createHash("sha256").update(process.cwd()).digest("hex").slice(0, 16);
50
+ // Keyed by CC session ID so two Claude Code sessions in the same directory
51
+ // get independent state files and never auto-reconnect into each other's
52
+ // project. Previously keyed by cwd hash, which caused unrelated sessions to
53
+ // silently inherit another session's project connection (#154, #153).
54
+ const hash = createHash("sha256").update(CC_SESSION_ID).digest("hex").slice(0, 16);
51
55
  return `${homedir()}/.polaris/sessions/${hash}.json`;
52
56
  }
53
57
 
54
58
  async function saveSessionState(project: string, session: string, user: string, profile: string): Promise<void> {
55
59
  try {
56
60
  await mkdir(`${homedir()}/.polaris/sessions`, { recursive: true });
57
- await writeFile(sessionStatePath(), JSON.stringify({ project, session, user, profile, cwd: process.cwd() }));
61
+ await writeFile(sessionStatePath(), JSON.stringify({ project, session, user, profile, cwd: process.cwd(), ccSessionId: CC_SESSION_ID }));
58
62
  } catch { /* best-effort */ }
59
63
  }
60
64
 
@@ -188,6 +192,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
188
192
  properties: {
189
193
  duration: { type: "string", description: "Time range to backfill (e.g., '2h', '30m', '1d'). Auto-detects if omitted." },
190
194
  from: { type: "string", description: "ISO timestamp to backfill from. Overrides duration." },
195
+ dry_run: { type: "boolean", description: "If true, report how many events would be sent without actually sending them." },
191
196
  },
192
197
  },
193
198
  },
@@ -393,16 +398,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
393
398
  if (!currentProject) {
394
399
  return { content: [{ type: "text", text: "Not connected to a Polaris session. Use polaris_connect first." }] };
395
400
  }
396
- const { duration, from } = (args ?? {}) as { duration?: string; from?: string };
401
+ const { duration, from, dry_run } = (args ?? {}) as { duration?: string; from?: string; dry_run?: boolean };
397
402
  try {
398
403
  const res = await daemonPost("/backfill", {
399
404
  ccSessionId: CC_SESSION_ID,
400
405
  ...(duration ? { duration } : {}),
401
406
  ...(from ? { from } : {}),
407
+ ...(dry_run ? { dry_run: true } : {}),
402
408
  });
403
- const body = await res.json() as { recovered: number; source: string; gaps: string[] };
409
+ const body = await res.json() as { recovered: number; would_recover?: number; source: string; gaps: string[] };
404
410
  if (res.ok) {
405
411
  const gapInfo = body.gaps.length > 0 ? `\nGaps: ${body.gaps.join(", ")}` : "";
412
+ if (dry_run) {
413
+ return { content: [{ type: "text", text: `Dry run: ${body.would_recover ?? 0} events in daemon log would be sent.${gapInfo}` }] };
414
+ }
406
415
  return { content: [{ type: "text", text: `Backfill complete: ${body.recovered} events recovered from ${body.source}.${gapInfo}` }] };
407
416
  }
408
417
  return { content: [{ type: "text", text: `Backfill failed: ${(body as unknown as { error?: string }).error ?? "unknown error"}` }] };
@@ -456,6 +465,34 @@ async function main() {
456
465
  }
457
466
  }
458
467
 
468
+ // Heartbeat: check every 60s whether the daemon still has our session mapped.
469
+ // If not (dropped WS, daemon restart, compaction), silently re-connect so
470
+ // activity reaches the channel without a manual /polaris join.
471
+ setInterval(async () => {
472
+ if (!currentProject) return; // not connected — nothing to heartbeat
473
+ try {
474
+ const res = await daemonGet(`/status/${CC_SESSION_ID}`);
475
+ const body = await res.json() as { connected?: boolean; project?: string; session?: string };
476
+ if (body.connected) return; // all good
477
+ // Daemon lost the mapping — attempt to reconnect to the same session
478
+ const reconnectRes = await daemonPost("/connect", {
479
+ ccSessionId: CC_SESSION_ID,
480
+ project: currentProject,
481
+ session: currentSession,
482
+ user: currentUser,
483
+ profile: currentProfile,
484
+ });
485
+ if (reconnectRes.ok) {
486
+ console.error(`Polaris: session dropped; auto-reconnected to #${currentProject}/${currentSession}`);
487
+ } else {
488
+ console.error(`Polaris: session dropped and reconnect failed. Run /polaris to check status.`);
489
+ currentProject = "";
490
+ currentSession = "";
491
+ currentUser = "";
492
+ }
493
+ } catch { /* daemon unreachable — will retry next heartbeat */ }
494
+ }, 60_000);
495
+
459
496
  // inject delivery is handled via the UserPromptSubmit hook (see daemon injectQueues); claude/channel push deferred
460
497
 
461
498
  const transport = new StdioServerTransport();
@@ -15,6 +15,7 @@ interface SessionMapping {
15
15
  slackChannel?: string;
16
16
  ws: WebSocket | null;
17
17
  pendingMapping?: boolean; // true until the first hook event from this shell claims the mapping
18
+ closingIntentionally?: boolean; // set before deliberate ws.close() so onclose can distinguish drops
18
19
  }
19
20
 
20
21
  function generateSessionName(): string {
@@ -164,14 +165,18 @@ function connectCloudWs(mapping: SessionMapping) {
164
165
  };
165
166
 
166
167
  ws.onclose = () => {
167
- // Reconnect if still registered
168
- if (sessions.has(mapping.ccSessionId) && sessions.get(mapping.ccSessionId)!.project === mapping.project) {
168
+ const m = sessions.get(mapping.ccSessionId);
169
+ if (m && m.project === mapping.project && mapping.project !== "" && !m.closingIntentionally) {
170
+ // Unexpected cloud WS drop — log it so the user has a record
171
+ void logSessionChange("ws-drop", mapping.ccSessionId, mapping.project, mapping.session, mapping.user, mapping.agent);
169
172
  setTimeout(() => {
170
- if (sessions.has(mapping.ccSessionId)) {
171
- connectCloudWs(sessions.get(mapping.ccSessionId)!);
173
+ const current = sessions.get(mapping.ccSessionId);
174
+ if (current && current.project) {
175
+ connectCloudWs(current);
172
176
  }
173
177
  }, 3000);
174
178
  }
179
+ if (m) m.closingIntentionally = false;
175
180
  };
176
181
 
177
182
  ws.onerror = () => {
@@ -184,6 +189,7 @@ function connectCloudWs(mapping: SessionMapping) {
184
189
  function disconnectCloudWs(ccSessionId: string) {
185
190
  const mapping = sessions.get(ccSessionId);
186
191
  if (mapping?.ws) {
192
+ mapping.closingIntentionally = true;
187
193
  mapping.ws.close();
188
194
  mapping.ws = null;
189
195
  }
@@ -310,7 +316,7 @@ async function scanOutbox(): Promise<void> {
310
316
 
311
317
  const SESSION_HISTORY_FILE = join(homedir(), ".polaris", "session-history.jsonl");
312
318
 
313
- async function logSessionChange(action: "connect" | "disconnect", ccSessionId: string, project: string, session: string, user: string, agent: string): Promise<void> {
319
+ async function logSessionChange(action: "connect" | "disconnect" | "ws-drop", ccSessionId: string, project: string, session: string, user: string, agent: string): Promise<void> {
314
320
  try {
315
321
  await ensureLogDir();
316
322
  const entry = JSON.stringify({ t: new Date().toISOString(), action, ccSessionId, project, session, user, agent });
@@ -320,7 +326,7 @@ async function logSessionChange(action: "connect" | "disconnect", ccSessionId: s
320
326
 
321
327
  // --- Backfill: recover lost events from daemon log ---
322
328
 
323
- async function backfill(mapping: SessionMapping, duration?: string, from?: string): Promise<{ recovered: number; source: string; gaps: string[] }> {
329
+ async function backfill(mapping: SessionMapping, duration?: string, from?: string, dryRun = false): Promise<{ recovered: number; would_recover?: number; source: string; gaps: string[] }> {
324
330
  const now = new Date();
325
331
  let since: Date;
326
332
 
@@ -389,6 +395,11 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
389
395
  return { recovered: 0, source: "daemon_log", gaps: [`no log entries found since ${since.toISOString()}`] };
390
396
  }
391
397
 
398
+ // Dry-run: report how many events would be sent without actually sending
399
+ if (dryRun) {
400
+ return { recovered: 0, would_recover: logEntries.length, source: "daemon_log", gaps: [] };
401
+ }
402
+
392
403
  // Replay events to the API
393
404
  let recovered = 0;
394
405
  const serviceUrl = getServiceUrlForProfile(mapping.profile);
@@ -929,12 +940,12 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
929
940
  // POST /backfill — recover lost events from daemon log
930
941
  if (method === "POST" && pathname === "/backfill") {
931
942
  try {
932
- const body = (await req.json()) as { ccSessionId: string; duration?: string; from?: string };
943
+ const body = (await req.json()) as { ccSessionId: string; duration?: string; from?: string; dry_run?: boolean };
933
944
  if (!body.ccSessionId) return error("ccSessionId required", 400);
934
945
  const mapping = sessions.get(body.ccSessionId);
935
946
  if (!mapping || !mapping.project) return error("Not connected", 400);
936
947
 
937
- const result = await backfill(mapping, body.duration, body.from);
948
+ const result = await backfill(mapping, body.duration, body.from, body.dry_run ?? false);
938
949
  return json(result);
939
950
  } catch {
940
951
  return error("Invalid JSON", 400);