@lightupai/polaris 0.0.76 → 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/.github/workflows/cd.yml +77 -0
- package/docs/architecture.md +203 -0
- package/docs/product-critique-2026-06.md +136 -0
- package/docs/readiness-review-2026-06.md +130 -0
- package/hooks/capture-prompt.ts +60 -0
- package/hooks/capture-stop.ts +10 -3
- package/hooks/capture.sh +12 -0
- package/hooks/statusline.sh +8 -2
- package/package.json +1 -1
- package/scripts/backup.sh +71 -0
- package/src/bridge-discover-org.ts +30 -4
- package/src/cli/cli.ts +304 -34
- package/src/client/client.ts +44 -11
- package/src/daemon/daemon.ts +262 -38
- package/src/service/auth.ts +10 -1
- package/src/service/db.ts +338 -63
- package/src/service/server.ts +267 -11
- package/src/slack/bridge.ts +172 -34
- package/src/types.ts +22 -0
- package/src/web/app.ts +373 -2
- package/src/web/layout.ts +5 -1
- package/src/web/views.ts +420 -6
- package/tests/acl.test.ts +100 -0
- package/tests/annotations.test.ts +209 -0
- package/tests/bridge-discover.test.ts +70 -0
- package/tests/daemon-auth.test.ts +123 -0
- package/tests/event-lookup.test.ts +113 -0
- package/tests/label.test.ts +90 -0
- package/tests/pagination.test.ts +165 -0
- package/tests/recover.test.ts +83 -0
- package/tests/reply-attribution.test.ts +103 -0
- package/tests/search-acl.test.ts +117 -0
- package/tests/search.test.ts +145 -0
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
|
|
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:
|
|
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
|
-
//
|
|
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);
|