@lightupai/polaris 0.0.77 → 0.0.78

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/src/cli/cli.ts CHANGED
@@ -11,9 +11,10 @@
11
11
  // polaris profiles — list profiles
12
12
  // polaris daemon — start the local daemon
13
13
  // polaris status — show connection status
14
+ // polaris recover — re-POST locally logged events missing upstream
14
15
  // polaris logout — remove credentials
15
16
 
16
- import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
17
+ import { mkdir, writeFile, readFile, rm, copyFile, chmod, readdir } from "node:fs/promises";
17
18
  import { homedir, hostname } from "node:os";
18
19
  import { join } from "node:path";
19
20
 
@@ -40,6 +41,7 @@ interface Profile {
40
41
  interface Config {
41
42
  active: string;
42
43
  profiles: Record<string, Profile>;
44
+ daemonSecret?: string;
43
45
  }
44
46
 
45
47
  async function loadConfig(): Promise<Config> {
@@ -104,11 +106,91 @@ function appToApi(appUrl: string): string {
104
106
  return appUrl.replace("app.", "api.");
105
107
  }
106
108
 
109
+ // --- Daemon secret ---
110
+
111
+ // Shared local secret for daemon HTTP auth. Stored in ~/.polaris/config.json
112
+ // (`daemonSecret`); the daemon requires it as `x-polaris-daemon-secret` on all
113
+ // of its endpoints once a secret is resolved (env POLARIS_DAEMON_SECRET takes
114
+ // precedence over the config file). Reused across installs so previously
115
+ // registered MCP servers and hooks stay valid.
116
+ async function ensureDaemonSecret(): Promise<string> {
117
+ const config = await loadConfig();
118
+ const envSecret = process.env.POLARIS_DAEMON_SECRET;
119
+ if (envSecret) {
120
+ if (config.daemonSecret !== envSecret) {
121
+ config.daemonSecret = envSecret;
122
+ await saveConfig(config);
123
+ }
124
+ return envSecret;
125
+ }
126
+ if (config.daemonSecret) return config.daemonSecret;
127
+ const secret = crypto.randomUUID();
128
+ config.daemonSecret = secret;
129
+ await saveConfig(config);
130
+ return secret;
131
+ }
132
+
133
+ function resolveDaemonSecret(config: Config): string | undefined {
134
+ return process.env.POLARIS_DAEMON_SECRET || config.daemonSecret || undefined;
135
+ }
136
+
107
137
  // --- Install ---
108
138
 
139
+ // Wire Claude Code hooks + status line into ~/.claude/settings.json.
140
+ // UserPromptSubmit/Stop run bun scripts (prompt-time inject delivery and full
141
+ // Stop transcript capture); PreToolUse/PostToolUse use the plain curl relay.
142
+ // Hooks are copied to ~/.polaris/hooks/ — a stable path that survives package
143
+ // upgrades/relocations — and re-copied on every install so they stay fresh.
144
+ async function wireHooks(daemonSecret?: string): Promise<void> {
145
+ const srcHooksDir = join(import.meta.dir, "..", "..", "hooks");
146
+ const hooksDir = join(POLARIS_DIR, "hooks");
147
+ await mkdir(hooksDir, { recursive: true });
148
+ for (const file of ["capture.sh", "capture-prompt.ts", "capture-stop.ts", "statusline.sh"]) {
149
+ await copyFile(join(srcHooksDir, file), join(hooksDir, file));
150
+ if (file.endsWith(".sh")) await chmod(join(hooksDir, file), 0o755);
151
+ }
152
+
153
+ const captureShPath = join(hooksDir, "capture.sh");
154
+ const capturePromptPath = join(hooksDir, "capture-prompt.ts");
155
+ const captureStopPath = join(hooksDir, "capture-stop.ts");
156
+ const statusLinePath = join(hooksDir, "statusline.sh");
157
+
158
+ // Pass the shared daemon secret to hook processes via the command env so
159
+ // they can authenticate with the daemon (x-polaris-daemon-secret header).
160
+ const env = daemonSecret ? `POLARIS_DAEMON_SECRET=${daemonSecret} ` : "";
161
+ const hooksConfig = {
162
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: `${env}npx bun "${capturePromptPath}"` }] }],
163
+ Stop: [{ hooks: [{ type: "command", command: `${env}npx bun "${captureStopPath}"` }] }],
164
+ PreToolUse: [{ hooks: [{ type: "command", command: `${env}"${captureShPath}"` }] }],
165
+ PostToolUse: [{ hooks: [{ type: "command", command: `${env}"${captureShPath}"` }] }],
166
+ };
167
+
168
+ const settingsPath = join(CLAUDE_DIR, "settings.json");
169
+ let existingSettings: Record<string, unknown> = {};
170
+ try {
171
+ existingSettings = JSON.parse(await readFile(settingsPath, "utf-8"));
172
+ } catch { /* doesn't exist yet */ }
173
+
174
+ const mergedSettings = {
175
+ ...existingSettings,
176
+ hooks: {
177
+ ...(existingSettings as { hooks?: Record<string, unknown> }).hooks,
178
+ ...hooksConfig,
179
+ },
180
+ statusLine: {
181
+ type: "command",
182
+ command: `${env}"${statusLinePath}"`,
183
+ },
184
+ };
185
+ await writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2));
186
+ }
187
+
109
188
  async function install(participantId?: string) {
110
189
  await mkdir(CLAUDE_DIR, { recursive: true });
111
190
 
191
+ // Shared local secret for daemon auth (generated once, reused thereafter)
192
+ const daemonSecret = await ensureDaemonSecret();
193
+
112
194
  // Install package to ~/.polaris/mcp/ (user-local, no sudo needed, persistent)
113
195
  const mcpDir = join(POLARIS_DIR, "mcp");
114
196
  await mkdir(mcpDir, { recursive: true });
@@ -133,7 +215,12 @@ async function install(participantId?: string) {
133
215
  stdout: "ignore", stderr: "ignore",
134
216
  });
135
217
  const mcpAdd = Bun.spawnSync(
136
- ["claude", "mcp", "add", "polaris", "-s", "user", "-e", "POLARIS_DAEMON_URL=http://127.0.0.1:4322", "--", mcpBin],
218
+ [
219
+ "claude", "mcp", "add", "polaris", "-s", "user",
220
+ "-e", "POLARIS_DAEMON_URL=http://127.0.0.1:4322",
221
+ "-e", `POLARIS_DAEMON_SECRET=${daemonSecret}`,
222
+ "--", mcpBin,
223
+ ],
137
224
  { stdout: "pipe", stderr: "pipe" }
138
225
  );
139
226
  if (mcpAdd.exitCode === 0) {
@@ -141,38 +228,11 @@ async function install(participantId?: string) {
141
228
  } else {
142
229
  console.error(" Warning: could not register MCP server with Claude Code.");
143
230
  console.error(" " + mcpAdd.stderr.toString().trim());
144
- console.error(` Run manually: claude mcp add -s user -e POLARIS_DAEMON_URL=http://127.0.0.1:4322 polaris -- ${mcpBin}`);
231
+ console.error(` Run manually: claude mcp add -s user -e POLARIS_DAEMON_URL=http://127.0.0.1:4322 -e POLARIS_DAEMON_SECRET=${daemonSecret} polaris -- ${mcpBin}`);
145
232
  }
146
233
 
147
- // Hooks
148
- const captureShPath = join(import.meta.dir, "..", "..", "hooks", "capture.sh");
149
- const hooksConfig = {
150
- UserPromptSubmit: [{ hooks: [{ type: "command", command: captureShPath }] }],
151
- Stop: [{ hooks: [{ type: "command", command: captureShPath }] }],
152
- PreToolUse: [{ hooks: [{ type: "command", command: captureShPath }] }],
153
- PostToolUse: [{ hooks: [{ type: "command", command: captureShPath }] }],
154
- };
155
-
156
- const settingsPath = join(CLAUDE_DIR, "settings.json");
157
- let existingSettings: Record<string, unknown> = {};
158
- try {
159
- existingSettings = JSON.parse(await readFile(settingsPath, "utf-8"));
160
- } catch { /* doesn't exist yet */ }
161
-
162
- // Status line
163
- const statusLinePath = join(import.meta.dir, "..", "..", "hooks", "statusline.sh");
164
- const mergedSettings = {
165
- ...existingSettings,
166
- hooks: {
167
- ...(existingSettings as { hooks?: Record<string, unknown> }).hooks,
168
- ...hooksConfig,
169
- },
170
- statusLine: {
171
- type: "command",
172
- command: statusLinePath,
173
- },
174
- };
175
- await writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2));
234
+ // Hooks (copied to ~/.polaris/hooks/ with the daemon secret in their env)
235
+ await wireHooks(daemonSecret);
176
236
  console.log(" ✓ Hooks + status line config written");
177
237
 
178
238
  // /polaris skill
@@ -347,6 +407,10 @@ Based on the arguments provided, do ONE of the following:
347
407
  `;
348
408
  await writeFile(join(skillDir, "SKILL.md"), skillContent);
349
409
 
410
+ // Hooks (same wiring as install — keeps hook commands up to date)
411
+ await wireHooks(await ensureDaemonSecret());
412
+ console.log(" ✓ Hooks + status line config written");
413
+
350
414
  // Post system event (device connected)
351
415
  const apiUrl = appToApi(appUrl);
352
416
  try {
@@ -448,9 +512,13 @@ async function status() {
448
512
  console.log("Not logged in. Run: polaris login");
449
513
  }
450
514
 
451
- // Daemon
515
+ // Daemon (send the shared local secret when configured — the daemon
516
+ // requires it on all endpoints once one is resolved)
452
517
  try {
453
- const res = await fetch("http://127.0.0.1:4322/status");
518
+ const secret = resolveDaemonSecret(config);
519
+ const res = await fetch("http://127.0.0.1:4322/status", {
520
+ headers: secret ? { "x-polaris-daemon-secret": secret } : undefined,
521
+ });
454
522
  const data = (await res.json()) as { ok: boolean; sessions?: Array<{ ccSessionId: string; project: string; session: string; user: string }> };
455
523
  if (data.ok) {
456
524
  console.log("\nDaemon: running");
@@ -468,6 +536,203 @@ async function status() {
468
536
  }
469
537
  }
470
538
 
539
+ // --- Recover ---
540
+
541
+ // Replay locally logged daemon events (~/.polaris/logs/*.jsonl) that never
542
+ // made it upstream. Presence is determined by fetching each session's
543
+ // messages (GET /projects/:p/sessions/:s/messages) and diffing: by event id
544
+ // when the logged payload carries one, otherwise by the payload's identifying
545
+ // fields (hook ids are assigned server-side at ingest, so hook log entries
546
+ // have no id of their own). Missing events are re-POSTed via the normal
547
+ // events ingest path. Best-effort: failures are skipped, never throws.
548
+
549
+ interface RecoverRoute { project: string; session: string; user: string; agent: string }
550
+ interface RecoverCandidate { project: string; session: string; sender: string; payload: Record<string, unknown> }
551
+
552
+ function isPresentUpstream(
553
+ payload: Record<string, unknown>,
554
+ upstream: Array<{ id?: string; payload?: Record<string, unknown> }>
555
+ ): boolean {
556
+ const id = payload.id;
557
+ if (typeof id === "string" && upstream.some((e) => e.id === id)) return true;
558
+ const kind = payload.hook_event_name;
559
+ return upstream.some((e) => {
560
+ const p = e.payload;
561
+ if (!p || p.hook_event_name !== kind || p.session_id !== payload.session_id) return false;
562
+ switch (kind) {
563
+ case "UserPromptSubmit":
564
+ return p.prompt === payload.prompt;
565
+ case "Stop":
566
+ return (p.stop_response ?? p.last_assistant_message) === (payload.stop_response ?? payload.last_assistant_message);
567
+ case "PreToolUse":
568
+ case "PostToolUse":
569
+ return p.tool_name === payload.tool_name && JSON.stringify(p.tool_input) === JSON.stringify(payload.tool_input);
570
+ default:
571
+ return JSON.stringify(p) === JSON.stringify(payload);
572
+ }
573
+ });
574
+ }
575
+
576
+ async function recover() {
577
+ try {
578
+ const config = await loadConfig();
579
+ const profile = getActiveProfile(config);
580
+ if (!profile) {
581
+ console.log("Not logged in. Run: polaris login");
582
+ return;
583
+ }
584
+ const headers = {
585
+ "Content-Type": "application/json",
586
+ Authorization: `Bearer ${profile.token}`,
587
+ };
588
+
589
+ const logDir = join(POLARIS_DIR, "logs");
590
+ let files: string[] = [];
591
+ try {
592
+ files = (await readdir(logDir)).filter((f) => f.endsWith(".jsonl")).sort();
593
+ } catch { /* no log dir */ }
594
+ if (files.length === 0) {
595
+ console.log("No local logs found in ~/.polaris/logs — nothing to recover.");
596
+ return;
597
+ }
598
+
599
+ // Pass 1: walk logs chronologically (files are daemon-YYYY-MM-DD.jsonl),
600
+ // learning session routing from /connect entries and collecting candidate
601
+ // events. Failed relays are logged twice (with/without a response field),
602
+ // so dedupe identical payloads.
603
+ const routes = new Map<string, RecoverRoute>(); // keyed by cc session id
604
+ const candidates: RecoverCandidate[] = [];
605
+ const seen = new Set<string>();
606
+ let scanned = 0;
607
+ let unroutable = 0;
608
+
609
+ const routeFor = (sessionId: string): RecoverRoute | undefined => {
610
+ // Exact match first; like the daemon, fall back to the single known
611
+ // session when only one mapping exists (MCP and hook session ids differ)
612
+ const route = routes.get(sessionId);
613
+ if (route) return route;
614
+ if (routes.size === 1) return routes.values().next().value;
615
+ return undefined;
616
+ };
617
+
618
+ for (const file of files) {
619
+ let text: string;
620
+ try {
621
+ text = await readFile(join(logDir, file), "utf-8");
622
+ } catch {
623
+ continue;
624
+ }
625
+ for (const line of text.split("\n")) {
626
+ if (!line.trim()) continue;
627
+ let entry: { endpoint?: string; payload?: Record<string, unknown> };
628
+ try {
629
+ entry = JSON.parse(line);
630
+ } catch {
631
+ continue;
632
+ }
633
+ const payload = entry.payload;
634
+ if (!payload || typeof payload !== "object") continue;
635
+
636
+ if (entry.endpoint === "/connect") {
637
+ // Routing is only learnable for explicit session names; generated
638
+ // names are assigned by the daemon after logging
639
+ const c = payload as { ccSessionId?: string; project?: string; session?: string; user?: string; agent?: string };
640
+ if (c.ccSessionId && c.project && c.session && c.user) {
641
+ routes.set(c.ccSessionId, { project: c.project, session: c.session, user: c.user, agent: c.agent || "agent:claude" });
642
+ }
643
+ continue;
644
+ }
645
+
646
+ if (entry.endpoint === "/events") {
647
+ const hookEvent = payload.hook_event_name;
648
+ const sessionId = payload.session_id;
649
+ if (typeof hookEvent !== "string" || typeof sessionId !== "string") continue;
650
+ const key = `events:${JSON.stringify(payload)}`;
651
+ if (seen.has(key)) continue;
652
+ seen.add(key);
653
+ scanned++;
654
+ const route = routeFor(sessionId);
655
+ if (!route) {
656
+ unroutable++;
657
+ continue;
658
+ }
659
+ const sender = hookEvent === "UserPromptSubmit" ? route.user : route.agent;
660
+ candidates.push({ project: route.project, session: route.session, sender, payload });
661
+ continue;
662
+ }
663
+
664
+ if (entry.endpoint === "/reply") {
665
+ const r = payload as { ccSessionId?: string; message?: string };
666
+ if (!r.ccSessionId || !r.message) continue;
667
+ const key = `reply:${JSON.stringify(payload)}`;
668
+ if (seen.has(key)) continue;
669
+ seen.add(key);
670
+ scanned++;
671
+ const route = routeFor(r.ccSessionId);
672
+ if (!route) {
673
+ unroutable++;
674
+ continue;
675
+ }
676
+ // Reconstruct the same Stop payload the daemon relays for replies
677
+ candidates.push({
678
+ project: route.project,
679
+ session: route.session,
680
+ sender: route.agent,
681
+ payload: { hook_event_name: "Stop", session_id: r.ccSessionId, stop_response: r.message },
682
+ });
683
+ }
684
+ }
685
+ }
686
+
687
+ // Pass 2: per session, fetch upstream messages and re-POST what's missing
688
+ const groups = new Map<string, RecoverCandidate[]>();
689
+ for (const c of candidates) {
690
+ const key = `${c.project}${c.session}`;
691
+ let group = groups.get(key);
692
+ if (!group) {
693
+ group = [];
694
+ groups.set(key, group);
695
+ }
696
+ group.push(c);
697
+ }
698
+
699
+ let missing = 0;
700
+ let restored = 0;
701
+ for (const group of groups.values()) {
702
+ const { project, session } = group[0];
703
+ let upstream: Array<{ id?: string; payload?: Record<string, unknown> }> = [];
704
+ try {
705
+ const res = await fetch(`${profile.api}/projects/${project}/sessions/${session}/messages`, { headers });
706
+ if (res.ok) upstream = (await res.json()) as typeof upstream;
707
+ } catch { /* unreachable — treat as empty */ }
708
+
709
+ for (const c of group) {
710
+ if (isPresentUpstream(c.payload, upstream)) continue;
711
+ missing++;
712
+ try {
713
+ const res = await fetch(`${profile.api}/projects/${project}/sessions/${session}/events`, {
714
+ method: "POST",
715
+ headers,
716
+ body: JSON.stringify({ sender: c.sender, payload: c.payload }),
717
+ });
718
+ if (res.ok) restored++;
719
+ } catch { /* leave as missing */ }
720
+ }
721
+ }
722
+
723
+ console.log(`Recover: ${scanned} logged events scanned, ${missing} missing upstream, ${restored} restored.`);
724
+ if (unroutable > 0) {
725
+ console.log(`Skipped ${unroutable} event(s) with no recoverable project/session mapping.`);
726
+ }
727
+ if (missing > restored) {
728
+ console.log(`${missing - restored} event(s) could not be restored (API unreachable or rejected).`);
729
+ }
730
+ } catch (err) {
731
+ // Best-effort: report and exit cleanly, never throw
732
+ console.error(`Recover failed: ${err instanceof Error ? err.message : String(err)}`);
733
+ }
734
+ }
735
+
471
736
  // --- Logout ---
472
737
 
473
738
  async function logout(all = false) {
@@ -557,6 +822,10 @@ switch (command) {
557
822
  await status();
558
823
  break;
559
824
 
825
+ case "recover":
826
+ await recover();
827
+ break;
828
+
560
829
  case "logout":
561
830
  await logout(hasFlag("all"));
562
831
  break;
@@ -593,6 +862,7 @@ switch (command) {
593
862
  console.log(" polaris profiles — list all profiles");
594
863
  console.log(" polaris daemon — start the local daemon");
595
864
  console.log(" polaris status — show connection status");
865
+ console.log(" polaris recover — re-POST locally logged events missing upstream");
596
866
  console.log(" polaris logout — remove active profile");
597
867
  console.log(" polaris logout --all — remove all credentials");
598
868
  }
@@ -9,21 +9,36 @@ import {
9
9
 
10
10
  const DAEMON_URL = process.env.POLARIS_DAEMON_URL ?? "http://127.0.0.1:4322";
11
11
 
12
- // Generate a stable session ID for this MCP server instance
13
- const CC_SESSION_ID = process.env.POLARIS_CC_SESSION_ID ?? crypto.randomUUID();
12
+ // Shared local daemon-auth secret (installed by `polaris install`); sent as
13
+ // x-polaris-daemon-secret when present, else the daemon runs unauthenticated.
14
+ const DAEMON_SECRET = process.env.POLARIS_DAEMON_SECRET || null;
15
+
16
+ // Stable session ID for this MCP instance: explicit override, else Claude Code's
17
+ // session id if it exposes one, else a generated UUID (daemon learns the hook id as an alias).
18
+ const CC_SESSION_ID =
19
+ process.env.POLARIS_CC_SESSION_ID ??
20
+ process.env.CLAUDE_SESSION_ID ??
21
+ process.env.CLAUDE_CODE_SESSION_ID ??
22
+ crypto.randomUUID();
14
23
 
15
24
  // --- Daemon communication ---
16
25
 
26
+ function daemonHeaders(): Record<string, string> {
27
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
28
+ if (DAEMON_SECRET) headers["x-polaris-daemon-secret"] = DAEMON_SECRET;
29
+ return headers;
30
+ }
31
+
17
32
  async function daemonPost(path: string, body: unknown): Promise<Response> {
18
33
  return fetch(`${DAEMON_URL}${path}`, {
19
34
  method: "POST",
20
- headers: { "Content-Type": "application/json" },
35
+ headers: daemonHeaders(),
21
36
  body: JSON.stringify(body),
22
37
  });
23
38
  }
24
39
 
25
40
  async function daemonGet(path: string): Promise<Response> {
26
- return fetch(`${DAEMON_URL}${path}`);
41
+ return fetch(`${DAEMON_URL}${path}`, { headers: daemonHeaders() });
27
42
  }
28
43
 
29
44
  // --- Current connection state ---
@@ -38,6 +53,9 @@ const mcp = new Server(
38
53
  { name: "polaris", version: "0.0.1" },
39
54
  {
40
55
  capabilities: {
56
+ // claude/channel push delivery is off by default — injects reach the
57
+ // agent via the UserPromptSubmit hook (see daemon injectQueues). See
58
+ // deliverInjectViaChannel below for the opt-in push scaffold.
41
59
  experimental: { "claude/channel": {} },
42
60
  tools: {},
43
61
  },
@@ -132,6 +150,27 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
132
150
  ],
133
151
  }));
134
152
 
153
+ // --- claude/channel push adapter (SCAFFOLD — off by default) ---
154
+ // With POLARIS_ENABLE_CHANNEL=1, push an inject to Claude Code in real time via an
155
+ // experimental claude/channel notification instead of waiting for the next
156
+ // UserPromptSubmit hook. Off by default (needs Claude Code to allowlist the channel);
157
+ // the hook path stays the primary delivery mechanism. Best-effort -> returns false.
158
+ export async function deliverInjectViaChannel(
159
+ content: string,
160
+ meta: Record<string, unknown> = {}
161
+ ): Promise<boolean> {
162
+ if (process.env.POLARIS_ENABLE_CHANNEL !== "1") return false;
163
+ try {
164
+ await mcp.notification({
165
+ method: "notifications/claude/channel",
166
+ params: { content, meta },
167
+ });
168
+ return true;
169
+ } catch {
170
+ return false; // hook-based delivery still applies
171
+ }
172
+ }
173
+
135
174
  mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
136
175
  const { name, arguments: args } = req.params;
137
176
 
@@ -325,13 +364,7 @@ async function main() {
325
364
  console.error("Warning: Polaris daemon not reachable. Start it with `bun run src/daemon/daemon.ts`.");
326
365
  }
327
366
 
328
- // Poll daemon for advisor messages and inject into MCP session
329
- setInterval(async () => {
330
- if (!currentProject) return;
331
- // The daemon's cloud WS forwards inject events to the mcpCallbacks map,
332
- // but since we're in a separate process, we use HTTP polling as a fallback.
333
- // In production, this would use IPC (Unix socket or named pipe).
334
- }, 5000);
367
+ // inject delivery is handled via the UserPromptSubmit hook (see daemon injectQueues); claude/channel push deferred
335
368
 
336
369
  const transport = new StdioServerTransport();
337
370
  await mcp.connect(transport);