@hienlh/ppm 0.9.82 → 0.9.83
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/CHANGELOG.md +10 -0
- package/dist/web/assets/{chat-tab-bS86TsT5.js → chat-tab-DBJJz0Dm.js} +1 -1
- package/dist/web/assets/{code-editor-BaNaQ33b.js → code-editor-BvSFsrGo.js} +1 -1
- package/dist/web/assets/{database-viewer-C5MVw8cJ.js → database-viewer-0P_zRC9w.js} +1 -1
- package/dist/web/assets/{diff-viewer-CUbFMWVo.js → diff-viewer-BmBJq4gO.js} +1 -1
- package/dist/web/assets/{extension-webview-CwGufYEP.js → extension-webview-Cx0GpRyC.js} +1 -1
- package/dist/web/assets/{git-graph-BD7A7MLo.js → git-graph-BAlhf058.js} +1 -1
- package/dist/web/assets/{index-CpzkPHOC.js → index-BDAqXmpQ.js} +4 -4
- package/dist/web/assets/keybindings-store-L7UlPjK0.js +1 -0
- package/dist/web/assets/{markdown-renderer-C19IsITh.js → markdown-renderer-CYs_lrjt.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BF79F1iL.js → port-forwarding-tab-CSHJ7gxM.js} +1 -1
- package/dist/web/assets/{postgres-viewer-_nYiO_wp.js → postgres-viewer-DlCLiEGU.js} +1 -1
- package/dist/web/assets/{settings-tab-C1SQMbSu.js → settings-tab-Dcr7lDcJ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-6OFvxxuN.js → sql-query-editor-Dxx-QZaI.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-SNVYFXvB.js → sqlite-viewer-B0052fC-.js} +1 -1
- package/dist/web/assets/{terminal-tab-BJEkmrDt.js → terminal-tab-yfmxGQ5e.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-r8FzlCWr.js → use-monaco-theme-7qyyJae5.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/cloud.ts +1 -0
- package/src/providers/claude-agent-sdk.ts +116 -11
- package/src/server/routes/port-forwarding.ts +118 -67
- package/src/services/supervisor.ts +8 -1
- package/src/web/hooks/use-url-sync.ts +6 -4
- package/.opencode/.env.example +0 -98
- package/.opencode/skills/ads-management/scripts/.env.example +0 -13
- package/.opencode/skills/ai-multimodal/.env.example +0 -230
- package/.opencode/skills/cip-design/.env.example +0 -6
- package/.opencode/skills/devops/.env.example +0 -76
- package/.opencode/skills/docs-seeker/.env.example +0 -15
- package/.opencode/skills/elevenlabs/.env.example +0 -3
- package/.opencode/skills/marketing-dashboard/.env.example +0 -15
- package/.opencode/skills/marketing-dashboard/app/.env.example +0 -2
- package/.opencode/skills/marketing-dashboard/server/.env.example +0 -2
- package/.opencode/skills/mcp-management/scripts/dist/analyze-tools.js +0 -70
- package/.opencode/skills/mcp-management/scripts/dist/cli.js +0 -160
- package/.opencode/skills/mcp-management/scripts/dist/mcp-client.js +0 -183
- package/.opencode/skills/payment-integration/scripts/.env.example +0 -20
- package/.opencode/skills/sequential-thinking/.env.example +0 -8
- package/dist/web/assets/keybindings-store-DsaANvBz.js +0 -1
|
@@ -20,7 +20,7 @@ import { getSessionProjectPath, setSessionMetadata, getSessionTitles } from "../
|
|
|
20
20
|
import { accountSelector } from "../services/account-selector.service.ts";
|
|
21
21
|
import { accountService, type AccountWithTokens } from "../services/account.service.ts";
|
|
22
22
|
import { resolve } from "node:path";
|
|
23
|
-
import { existsSync, readdirSync, unlinkSync } from "node:fs";
|
|
23
|
+
import { existsSync, readdirSync, unlinkSync, readFileSync, statSync } from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
|
|
26
26
|
const CLAUDE_PROJECTS_DIR = resolve(homedir(), ".claude/projects");
|
|
@@ -322,13 +322,30 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
322
322
|
// Overlay DB titles (user-set) over SDK titles
|
|
323
323
|
const ids = sdkSessions.map((s) => s.sessionId);
|
|
324
324
|
const dbTitles = getSessionTitles(ids);
|
|
325
|
-
|
|
325
|
+
const sessions: SessionInfo[] = sdkSessions.map((s) => ({
|
|
326
326
|
id: s.sessionId,
|
|
327
327
|
providerId: this.id,
|
|
328
328
|
title: dbTitles[s.sessionId] ?? s.customTitle ?? s.summary ?? s.firstPrompt ?? "Chat",
|
|
329
329
|
createdAt: new Date(s.lastModified).toISOString(),
|
|
330
330
|
updatedAt: new Date(s.lastModified).toISOString(),
|
|
331
331
|
}));
|
|
332
|
+
|
|
333
|
+
// SDK's listSessions drops sessions whose first user message exceeds its
|
|
334
|
+
// 64KB head buffer (e.g. large pasted docs). Scan JSONL dir to recover them.
|
|
335
|
+
if (dir && offset === 0) {
|
|
336
|
+
const knownIds = new Set(sessions.map((s) => s.id));
|
|
337
|
+
const missing = findMissingSessions(dir, knownIds, this.id);
|
|
338
|
+
if (missing.length > 0) {
|
|
339
|
+
const missingIds = missing.map((s) => s.id);
|
|
340
|
+
const missingDbTitles = getSessionTitles(missingIds);
|
|
341
|
+
for (const s of missing) {
|
|
342
|
+
s.title = missingDbTitles[s.id] ?? s.title;
|
|
343
|
+
}
|
|
344
|
+
sessions.push(...missing);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return sessions;
|
|
332
349
|
} catch {
|
|
333
350
|
return Array.from(this.activeSessions.values()).map((s) => ({
|
|
334
351
|
id: s.id,
|
|
@@ -343,15 +360,28 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
343
360
|
async getSessionInfoById(sessionId: string, dir?: string): Promise<SessionInfo | null> {
|
|
344
361
|
try {
|
|
345
362
|
const info = await sdkGetSessionInfo(sessionId, { dir });
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
363
|
+
if (info) {
|
|
364
|
+
const dbTitles = getSessionTitles([info.sessionId]);
|
|
365
|
+
return {
|
|
366
|
+
id: info.sessionId,
|
|
367
|
+
providerId: this.id,
|
|
368
|
+
title: dbTitles[info.sessionId] ?? info.customTitle ?? info.summary ?? info.firstPrompt ?? "Chat",
|
|
369
|
+
createdAt: new Date(info.lastModified).toISOString(),
|
|
370
|
+
updatedAt: new Date(info.lastModified).toISOString(),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// SDK can't find the session (large first message, active session, etc.)
|
|
374
|
+
// — try direct JSONL lookup
|
|
375
|
+
if (dir) {
|
|
376
|
+
const missing = findMissingSessions(dir, new Set(), this.id);
|
|
377
|
+
const match = missing.find((s) => s.id === sessionId);
|
|
378
|
+
if (match) {
|
|
379
|
+
const dbTitles = getSessionTitles([sessionId]);
|
|
380
|
+
match.title = dbTitles[sessionId] ?? match.title;
|
|
381
|
+
return match;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
355
385
|
} catch {
|
|
356
386
|
return null;
|
|
357
387
|
}
|
|
@@ -1633,6 +1663,81 @@ function extractText(message: unknown): string {
|
|
|
1633
1663
|
return "";
|
|
1634
1664
|
}
|
|
1635
1665
|
|
|
1666
|
+
/**
|
|
1667
|
+
* Scan a JSONL project directory for sessions that the SDK's listSessions missed.
|
|
1668
|
+
* The SDK uses a 64KB head buffer; sessions with very large first messages
|
|
1669
|
+
* (e.g., pasted API docs) can't be parsed and are silently dropped.
|
|
1670
|
+
* We extract title from queue-operation content or first user message.
|
|
1671
|
+
*/
|
|
1672
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
1673
|
+
function findMissingSessions(
|
|
1674
|
+
dir: string,
|
|
1675
|
+
knownIds: Set<string>,
|
|
1676
|
+
providerId: string,
|
|
1677
|
+
): SessionInfo[] {
|
|
1678
|
+
let resolvedDir: string;
|
|
1679
|
+
try { resolvedDir = require("node:fs").realpathSync(dir).normalize("NFC"); } catch { resolvedDir = dir; }
|
|
1680
|
+
// Match SDK's path encoding: replace all non-alphanumeric chars with "-"
|
|
1681
|
+
const encodedDir = resolvedDir.replace(/[^a-zA-Z0-9]/g, "-");
|
|
1682
|
+
let jsonlDir = resolve(CLAUDE_PROJECTS_DIR, encodedDir);
|
|
1683
|
+
// SDK truncates to 200 chars + hash for long paths; try prefix match as fallback
|
|
1684
|
+
if (!existsSync(jsonlDir) && encodedDir.length > 200) {
|
|
1685
|
+
const prefix = encodedDir.slice(0, 200);
|
|
1686
|
+
try {
|
|
1687
|
+
const match = readdirSync(CLAUDE_PROJECTS_DIR).find((d) => d.startsWith(prefix + "-"));
|
|
1688
|
+
if (match) jsonlDir = resolve(CLAUDE_PROJECTS_DIR, match);
|
|
1689
|
+
else return [];
|
|
1690
|
+
} catch { return []; }
|
|
1691
|
+
}
|
|
1692
|
+
if (!existsSync(jsonlDir)) return [];
|
|
1693
|
+
|
|
1694
|
+
const results: SessionInfo[] = [];
|
|
1695
|
+
for (const file of readdirSync(jsonlDir)) {
|
|
1696
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
1697
|
+
const id = file.slice(0, -6);
|
|
1698
|
+
if (!UUID_RE.test(id) || knownIds.has(id)) continue;
|
|
1699
|
+
|
|
1700
|
+
try {
|
|
1701
|
+
const filePath = resolve(jsonlDir, file);
|
|
1702
|
+
const stat = statSync(filePath);
|
|
1703
|
+
// Read first 512 bytes — enough to extract title from queue-operation content
|
|
1704
|
+
const buf = Buffer.alloc(512);
|
|
1705
|
+
const { openSync, readSync, closeSync } = require("node:fs") as typeof import("node:fs");
|
|
1706
|
+
const fd = openSync(filePath, "r");
|
|
1707
|
+
const bytesRead = readSync(fd, buf, 0, 512, 0);
|
|
1708
|
+
closeSync(fd);
|
|
1709
|
+
const head = buf.toString("utf-8", 0, bytesRead);
|
|
1710
|
+
|
|
1711
|
+
let title = "Chat";
|
|
1712
|
+
// Extract "content" value via string search (JSON may be truncated)
|
|
1713
|
+
const idx = head.indexOf('"content":"');
|
|
1714
|
+
if (idx >= 0) {
|
|
1715
|
+
const start = idx + 11; // length of '"content":"'
|
|
1716
|
+
// Read up to 120 chars or next unescaped quote
|
|
1717
|
+
let end = start;
|
|
1718
|
+
while (end < head.length && end - start < 200) {
|
|
1719
|
+
if (head[end] === "\\" ) { end += 2; continue; }
|
|
1720
|
+
if (head[end] === '"') break;
|
|
1721
|
+
end++;
|
|
1722
|
+
}
|
|
1723
|
+
const raw = head.slice(start, end).replace(/\\n/g, " ").replace(/\\"/g, '"').trim();
|
|
1724
|
+
if (raw.length > 0) {
|
|
1725
|
+
title = raw.length > 120 ? raw.slice(0, 120) + "…" : raw;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
results.push({
|
|
1730
|
+
id,
|
|
1731
|
+
providerId,
|
|
1732
|
+
title,
|
|
1733
|
+
createdAt: new Date(stat.mtime).toISOString(),
|
|
1734
|
+
updatedAt: new Date(stat.mtime).toISOString(),
|
|
1735
|
+
});
|
|
1736
|
+
} catch { /* skip unreadable files */ }
|
|
1737
|
+
}
|
|
1738
|
+
return results;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1636
1741
|
/** Strip SDK teammate-message XML tags from assistant text */
|
|
1637
1742
|
const TEAMMATE_MSG_RE = /<teammate-message[^>]*>[\s\S]*?<\/teammate-message>/g;
|
|
1638
1743
|
function stripTeammateXml(text: string): string {
|
|
@@ -19,11 +19,67 @@ interface ActiveTunnel {
|
|
|
19
19
|
url: string;
|
|
20
20
|
process: import("bun").Subprocess;
|
|
21
21
|
startedAt: number;
|
|
22
|
+
probeFailures: number;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
const MAX_PROBE_FAILURES = 2;
|
|
26
|
+
|
|
24
27
|
/** Active tunnels keyed by port — exported for testing */
|
|
25
28
|
export const activeTunnels = new Map<number, ActiveTunnel>();
|
|
26
29
|
|
|
30
|
+
/** Spawn cloudflared tunnel for a port, extract URL from stderr */
|
|
31
|
+
async function spawnTunnelProcess(port: number): Promise<{ process: import("bun").Subprocess; url: string }> {
|
|
32
|
+
const bin = await ensureCloudflared();
|
|
33
|
+
const proc = Bun.spawn(
|
|
34
|
+
[bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
|
|
35
|
+
{ stderr: "pipe", stdout: "ignore", stdin: "ignore" },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const reader = proc.stderr.getReader();
|
|
39
|
+
const decoder = new TextDecoder();
|
|
40
|
+
const url = await new Promise<string>((resolve, reject) => {
|
|
41
|
+
const timeout = setTimeout(() => {
|
|
42
|
+
try { proc.kill(); } catch {}
|
|
43
|
+
reject(new Error("Tunnel timed out after 30s"));
|
|
44
|
+
}, 30_000);
|
|
45
|
+
|
|
46
|
+
let buffer = "";
|
|
47
|
+
let found = false;
|
|
48
|
+
const read = async () => {
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) break;
|
|
53
|
+
if (found) continue;
|
|
54
|
+
buffer += decoder.decode(value, { stream: true });
|
|
55
|
+
const match = buffer.match(TUNNEL_URL_REGEX);
|
|
56
|
+
if (match) {
|
|
57
|
+
found = true;
|
|
58
|
+
buffer = "";
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
resolve(match[0]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!found) {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
reject(new Error("cloudflared exited without tunnel URL"));
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (!found) { clearTimeout(timeout); reject(e); }
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
read();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return { process: proc, url };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Register tunnel in map with auto-cleanup on exit */
|
|
78
|
+
function registerTunnel(port: number, proc: import("bun").Subprocess, url: string) {
|
|
79
|
+
activeTunnels.set(port, { port, url, process: proc, startedAt: Date.now(), probeFailures: 0 });
|
|
80
|
+
proc.exited.then(() => activeTunnels.delete(port)).catch(() => activeTunnels.delete(port));
|
|
81
|
+
}
|
|
82
|
+
|
|
27
83
|
/** Start a tunnel for a localhost port */
|
|
28
84
|
portForwardingRoutes.post("/tunnel", async (c) => {
|
|
29
85
|
const body = await c.req.json<{ port: number }>().catch(() => null);
|
|
@@ -39,54 +95,8 @@ portForwardingRoutes.post("/tunnel", async (c) => {
|
|
|
39
95
|
}
|
|
40
96
|
|
|
41
97
|
try {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
[bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
|
|
45
|
-
{ stderr: "pipe", stdout: "ignore", stdin: "ignore" },
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
// Read stderr to find tunnel URL
|
|
49
|
-
const reader = proc.stderr.getReader();
|
|
50
|
-
const decoder = new TextDecoder();
|
|
51
|
-
const url = await new Promise<string>((resolve, reject) => {
|
|
52
|
-
const timeout = setTimeout(() => {
|
|
53
|
-
try { proc.kill(); } catch {}
|
|
54
|
-
reject(new Error("Tunnel timed out after 30s"));
|
|
55
|
-
}, 30_000);
|
|
56
|
-
|
|
57
|
-
let buffer = "";
|
|
58
|
-
let found = false;
|
|
59
|
-
const read = async () => {
|
|
60
|
-
try {
|
|
61
|
-
while (true) {
|
|
62
|
-
const { done, value } = await reader.read();
|
|
63
|
-
if (done) break;
|
|
64
|
-
if (found) continue;
|
|
65
|
-
buffer += decoder.decode(value, { stream: true });
|
|
66
|
-
const match = buffer.match(TUNNEL_URL_REGEX);
|
|
67
|
-
if (match) {
|
|
68
|
-
found = true;
|
|
69
|
-
buffer = "";
|
|
70
|
-
clearTimeout(timeout);
|
|
71
|
-
resolve(match[0]);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
if (!found) {
|
|
75
|
-
clearTimeout(timeout);
|
|
76
|
-
reject(new Error("cloudflared exited without tunnel URL"));
|
|
77
|
-
}
|
|
78
|
-
} catch (e) {
|
|
79
|
-
if (!found) { clearTimeout(timeout); reject(e); }
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
read();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
activeTunnels.set(port, { port, url, process: proc, startedAt: Date.now() });
|
|
86
|
-
|
|
87
|
-
// Auto-cleanup when process exits
|
|
88
|
-
proc.exited.then(() => activeTunnels.delete(port)).catch(() => activeTunnels.delete(port));
|
|
89
|
-
|
|
98
|
+
const { process: proc, url } = await spawnTunnelProcess(port);
|
|
99
|
+
registerTunnel(port, proc, url);
|
|
90
100
|
console.log(`[preview] tunnel started for port ${port} → ${url}`);
|
|
91
101
|
return c.json(ok({ port, url }));
|
|
92
102
|
} catch (e: any) {
|
|
@@ -123,27 +133,68 @@ function isProcessAlive(proc: import("bun").Subprocess): boolean {
|
|
|
123
133
|
try { process.kill(proc.pid, 0); return true; } catch { return false; }
|
|
124
134
|
}
|
|
125
135
|
|
|
126
|
-
/**
|
|
136
|
+
/** Probe tunnel URL to check if it's still accessible (DNS + connection) */
|
|
137
|
+
async function probeTunnelUrl(url: string): Promise<boolean> {
|
|
138
|
+
try {
|
|
139
|
+
await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(8_000), redirect: "follow" });
|
|
140
|
+
return true;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let cleanupRunning = false;
|
|
147
|
+
|
|
148
|
+
/** Remove ghost tunnels and auto-restart tunnels with expired URLs */
|
|
127
149
|
async function cleanupGhostTunnels() {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
if (cleanupRunning) return;
|
|
151
|
+
cleanupRunning = true;
|
|
152
|
+
try {
|
|
153
|
+
for (const [port, tunnel] of activeTunnels) {
|
|
154
|
+
// Check if cloudflared process is still running
|
|
155
|
+
if (!isProcessAlive(tunnel.process)) {
|
|
156
|
+
console.log(`[preview] ghost cleanup: port ${port} — process dead`);
|
|
157
|
+
activeTunnels.delete(port);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// Check if target port is still listening
|
|
161
|
+
try {
|
|
162
|
+
const conn = await Bun.connect({ hostname: "127.0.0.1", port, socket: {
|
|
163
|
+
data() {}, open(s) { s.end(); }, error() {}, close() {},
|
|
164
|
+
}});
|
|
165
|
+
conn.end();
|
|
166
|
+
} catch {
|
|
167
|
+
console.log(`[preview] ghost cleanup: port ${port} — port not listening`);
|
|
168
|
+
try { tunnel.process.kill(); } catch {}
|
|
169
|
+
activeTunnels.delete(port);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Probe tunnel URL to detect expired Quick Tunnel URLs
|
|
174
|
+
const alive = await probeTunnelUrl(tunnel.url);
|
|
175
|
+
if (alive) {
|
|
176
|
+
tunnel.probeFailures = 0;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
tunnel.probeFailures++;
|
|
181
|
+
console.log(`[preview] tunnel probe failed for port ${port} (${tunnel.probeFailures}/${MAX_PROBE_FAILURES})`);
|
|
182
|
+
|
|
183
|
+
if (tunnel.probeFailures >= MAX_PROBE_FAILURES) {
|
|
184
|
+
console.log(`[preview] tunnel URL expired for port ${port}, restarting...`);
|
|
185
|
+
try { tunnel.process.kill(); } catch {}
|
|
186
|
+
activeTunnels.delete(port);
|
|
187
|
+
try {
|
|
188
|
+
const { process: proc, url } = await spawnTunnelProcess(port);
|
|
189
|
+
registerTunnel(port, proc, url);
|
|
190
|
+
console.log(`[preview] tunnel restarted for port ${port} → ${url}`);
|
|
191
|
+
} catch (e: any) {
|
|
192
|
+
console.warn(`[preview] tunnel restart failed for port ${port}: ${e.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
146
195
|
}
|
|
196
|
+
} finally {
|
|
197
|
+
cleanupRunning = false;
|
|
147
198
|
}
|
|
148
199
|
}
|
|
149
200
|
|
|
@@ -504,7 +504,8 @@ async function notifyStateChange(from: string, to: string, reason: string) {
|
|
|
504
504
|
/** Connect supervisor to Cloud via WebSocket (if device is linked) */
|
|
505
505
|
async function connectCloud(opts: { port: number }, serverArgs: string[], logFd: number): Promise<boolean> {
|
|
506
506
|
try {
|
|
507
|
-
const { getCloudDevice } = await import("./cloud.service.ts");
|
|
507
|
+
const { getCloudDevice, saveCloudDevice } = await import("./cloud.service.ts");
|
|
508
|
+
const { configService } = await import("./config.service.ts");
|
|
508
509
|
const device = getCloudDevice();
|
|
509
510
|
if (!device) return false; // not linked to cloud
|
|
510
511
|
|
|
@@ -520,6 +521,12 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
|
|
|
520
521
|
const status = readStatus();
|
|
521
522
|
// Re-read device file each heartbeat to pick up name changes
|
|
522
523
|
const currentDevice = getCloudDevice();
|
|
524
|
+
// Sync device name from config if user changed it in settings
|
|
525
|
+
const configName = configService.get("device_name") as string;
|
|
526
|
+
if (configName && currentDevice && configName !== currentDevice.name) {
|
|
527
|
+
currentDevice.name = configName;
|
|
528
|
+
saveCloudDevice(currentDevice);
|
|
529
|
+
}
|
|
523
530
|
return {
|
|
524
531
|
type: "heartbeat" as const,
|
|
525
532
|
tunnelUrl,
|
|
@@ -100,10 +100,12 @@ function buildMetadataFromUrl(
|
|
|
100
100
|
case "chat": {
|
|
101
101
|
if (!identifier) return null;
|
|
102
102
|
const slashIdx = identifier.indexOf("/");
|
|
103
|
-
|
|
104
|
-
const providerId = identifier.slice(0, slashIdx);
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
const rawSessionId = slashIdx === -1 ? identifier : identifier.slice(slashIdx + 1);
|
|
104
|
+
const providerId = slashIdx === -1 ? undefined : identifier.slice(0, slashIdx);
|
|
105
|
+
// Validate UUID format — short/random IDs from tab derivation must not be used as session IDs
|
|
106
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawSessionId);
|
|
107
|
+
const sessionId = isUuid ? rawSessionId : undefined;
|
|
108
|
+
return { ...(sessionId && { sessionId }), ...(providerId && { providerId }), projectName };
|
|
107
109
|
}
|
|
108
110
|
case "terminal": return { terminalIndex: parseInt(identifier ?? "1", 10), projectName };
|
|
109
111
|
case "git-graph": return { projectName };
|
package/.opencode/.env.example
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# Claude Code - Global Environment Variables
|
|
2
|
-
# Location: .claude/.env
|
|
3
|
-
# Priority: LOWEST (overridden by skills/.env and skill-specific .env)
|
|
4
|
-
# Scope: Project-wide configuration, global defaults
|
|
5
|
-
# Setup: Copy to .claude/.env and configure
|
|
6
|
-
|
|
7
|
-
# ============================================
|
|
8
|
-
# Environment Variable Hierarchy
|
|
9
|
-
# ============================================
|
|
10
|
-
# Priority order (highest to lowest):
|
|
11
|
-
# 1. process.env - Runtime environment (HIGHEST)
|
|
12
|
-
# 2. .claude/skills/<skill>/.env - Skill-specific overrides
|
|
13
|
-
# 3. .claude/skills/.env - Shared across all skills
|
|
14
|
-
# 4. .claude/.env - Global defaults (this file, LOWEST)
|
|
15
|
-
#
|
|
16
|
-
# All skills use centralized resolver: ~/.claude/scripts/resolve_env.py
|
|
17
|
-
# Debug hierarchy: python ~/.claude/scripts/resolve_env.py --show-hierarchy
|
|
18
|
-
|
|
19
|
-
# ============================================
|
|
20
|
-
# ClaudeKit API Key (for VidCap, ReviewWeb services)
|
|
21
|
-
# ============================================
|
|
22
|
-
# Get your API key from https://claudekit.cc/api-keys
|
|
23
|
-
# Required for accessing ClaudeKit services via skills
|
|
24
|
-
CLAUDEKIT_API_KEY=
|
|
25
|
-
|
|
26
|
-
# ============================================
|
|
27
|
-
# Claude Code Notification Hooks
|
|
28
|
-
# ============================================
|
|
29
|
-
# Discord Webhook URL (for Discord notifications)
|
|
30
|
-
# Get from: Server Settings → Integrations → Webhooks → New Webhook
|
|
31
|
-
DISCORD_WEBHOOK_URL=
|
|
32
|
-
|
|
33
|
-
# Telegram Bot Token (for Telegram notifications)
|
|
34
|
-
# Get from: @BotFather in Telegram
|
|
35
|
-
TELEGRAM_BOT_TOKEN=
|
|
36
|
-
|
|
37
|
-
# Telegram Chat ID (your chat ID or group ID)
|
|
38
|
-
# Get from: https://api.telegram.org/bot<BOT_TOKEN>/getUpdates
|
|
39
|
-
TELEGRAM_CHAT_ID=
|
|
40
|
-
|
|
41
|
-
# ============================================
|
|
42
|
-
# AI/ML API Keys (Global Defaults)
|
|
43
|
-
# ============================================
|
|
44
|
-
# Google Gemini API (for ai-multimodal, docs-seeker skills)
|
|
45
|
-
# Get from: https://aistudio.google.com/apikey
|
|
46
|
-
GEMINI_API_KEY=
|
|
47
|
-
|
|
48
|
-
# Vertex AI Configuration (Optional alternative to AI Studio)
|
|
49
|
-
# GEMINI_USE_VERTEX=true
|
|
50
|
-
# VERTEX_PROJECT_ID=
|
|
51
|
-
# VERTEX_LOCATION=us-central1
|
|
52
|
-
|
|
53
|
-
# OpenAI API Key (if using OpenAI-based skills)
|
|
54
|
-
# OPENAI_API_KEY=
|
|
55
|
-
|
|
56
|
-
# Anthropic API Key (if using Claude API directly)
|
|
57
|
-
# ANTHROPIC_API_KEY=
|
|
58
|
-
|
|
59
|
-
# ElevenLabs API Key
|
|
60
|
-
# Get your key at: https://elevenlabs.io/app/settings/api-keys
|
|
61
|
-
# ELEVENLABS_API_KEY=
|
|
62
|
-
|
|
63
|
-
# ============================================
|
|
64
|
-
# Development & CI/CD
|
|
65
|
-
# ============================================
|
|
66
|
-
# NODE_ENV=development
|
|
67
|
-
# DEBUG=false
|
|
68
|
-
# LOG_LEVEL=info
|
|
69
|
-
|
|
70
|
-
# ============================================
|
|
71
|
-
# Project Configuration
|
|
72
|
-
# ============================================
|
|
73
|
-
# PROJECT_NAME=claudekit-engineer
|
|
74
|
-
# ENVIRONMENT=local
|
|
75
|
-
|
|
76
|
-
# ============================================
|
|
77
|
-
# Example Usage Scenarios
|
|
78
|
-
# ============================================
|
|
79
|
-
# Scenario 1: Global default for all skills
|
|
80
|
-
# .claude/.env (this file): GEMINI_API_KEY=global-dev-key
|
|
81
|
-
# Result: All skills use global-dev-key
|
|
82
|
-
#
|
|
83
|
-
# Scenario 2: Override for all skills
|
|
84
|
-
# .claude/.env (this file): GEMINI_API_KEY=global-dev-key
|
|
85
|
-
# .claude/skills/.env: GEMINI_API_KEY=skills-prod-key
|
|
86
|
-
# Result: All skills use skills-prod-key
|
|
87
|
-
#
|
|
88
|
-
# Scenario 3: Skill-specific override
|
|
89
|
-
# .claude/.env (this file): GEMINI_API_KEY=global-key
|
|
90
|
-
# .claude/skills/.env: GEMINI_API_KEY=shared-key
|
|
91
|
-
# .claude/skills/ai-multimodal/.env: GEMINI_API_KEY=high-quota-key
|
|
92
|
-
# Result: ai-multimodal uses high-quota-key, other skills use shared-key
|
|
93
|
-
#
|
|
94
|
-
# Scenario 4: Runtime testing
|
|
95
|
-
# export GEMINI_API_KEY=test-key
|
|
96
|
-
# Result: All skills use test-key regardless of config files
|
|
97
|
-
#
|
|
98
|
-
# Priority: runtime > skill-specific > shared > global (this file)
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# Google Ads API credentials
|
|
2
|
-
GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token
|
|
3
|
-
GOOGLE_ADS_CLIENT_ID=your_client_id.apps.googleusercontent.com
|
|
4
|
-
GOOGLE_ADS_CLIENT_SECRET=your_client_secret
|
|
5
|
-
GOOGLE_ADS_REFRESH_TOKEN=1//your_refresh_token
|
|
6
|
-
GOOGLE_ADS_CUSTOMER_ID=1234567890
|
|
7
|
-
GOOGLE_ADS_LOGIN_CUSTOMER_ID=your_mcc_id
|
|
8
|
-
|
|
9
|
-
# Meta/Facebook Ads API credentials
|
|
10
|
-
META_APP_ID=your_app_id
|
|
11
|
-
META_APP_SECRET=your_app_secret
|
|
12
|
-
META_ACCESS_TOKEN=your_system_user_token
|
|
13
|
-
META_AD_ACCOUNT_ID=act_123456789
|