@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 +1 -1
- package/src/client/client.ts +41 -4
- package/src/daemon/daemon.ts +19 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightupai/polaris",
|
|
3
|
-
"version": "0.0.
|
|
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",
|
package/src/client/client.ts
CHANGED
|
@@ -47,14 +47,18 @@ async function daemonGet(path: string): Promise<Response> {
|
|
|
47
47
|
// --- Session state persistence ---
|
|
48
48
|
|
|
49
49
|
function sessionStatePath(): string {
|
|
50
|
-
|
|
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();
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
-
|
|
168
|
-
if (
|
|
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
|
-
|
|
171
|
-
|
|
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);
|