@hienlh/ppm 0.1.0
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/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { chatService } from "../../services/chat.service.ts";
|
|
2
|
+
import { providerRegistry } from "../../providers/registry.ts";
|
|
3
|
+
import { resolveProjectPath } from "../helpers/resolve-project.ts";
|
|
4
|
+
import type { ChatWsClientMessage } from "../../types/api.ts";
|
|
5
|
+
|
|
6
|
+
/** Tracks active chat WS connections: sessionId -> ws + abort controller + project context */
|
|
7
|
+
const activeSessions = new Map<
|
|
8
|
+
string,
|
|
9
|
+
{ providerId: string; ws: ChatWsSocket; abort?: AbortController; projectPath?: string }
|
|
10
|
+
>();
|
|
11
|
+
|
|
12
|
+
type ChatWsSocket = {
|
|
13
|
+
data: { type: string; sessionId: string; projectName?: string };
|
|
14
|
+
send: (data: string) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Chat WebSocket handler for Bun.serve().
|
|
19
|
+
* Protocol: JSON messages as defined in ChatWsClientMessage / ChatWsServerMessage.
|
|
20
|
+
*/
|
|
21
|
+
export const chatWebSocket = {
|
|
22
|
+
open(ws: ChatWsSocket) {
|
|
23
|
+
const { sessionId, projectName } = ws.data;
|
|
24
|
+
// Look up session's actual provider, default to "claude-sdk"
|
|
25
|
+
const session = chatService.getSession(sessionId);
|
|
26
|
+
const providerId = session?.providerId ?? "claude-sdk";
|
|
27
|
+
|
|
28
|
+
// Resolve projectPath for skills/settings support
|
|
29
|
+
let projectPath: string | undefined;
|
|
30
|
+
if (projectName) {
|
|
31
|
+
try { projectPath = resolveProjectPath(projectName); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Backfill projectPath on existing session
|
|
35
|
+
if (session && !session.projectPath && projectPath) {
|
|
36
|
+
session.projectPath = projectPath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
activeSessions.set(sessionId, { providerId, ws, projectPath });
|
|
40
|
+
ws.send(JSON.stringify({ type: "connected", sessionId }));
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async message(ws: ChatWsSocket, msg: string | ArrayBuffer | Uint8Array) {
|
|
44
|
+
const { sessionId } = ws.data;
|
|
45
|
+
const text =
|
|
46
|
+
typeof msg === "string" ? msg : new TextDecoder().decode(msg as ArrayBuffer);
|
|
47
|
+
|
|
48
|
+
let parsed: ChatWsClientMessage;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text) as ChatWsClientMessage;
|
|
51
|
+
} catch {
|
|
52
|
+
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const entry = activeSessions.get(sessionId);
|
|
57
|
+
const providerId = entry?.providerId ?? "mock";
|
|
58
|
+
|
|
59
|
+
if (parsed.type === "message") {
|
|
60
|
+
// Ensure provider session has projectPath for skills/settings support
|
|
61
|
+
if (entry?.projectPath) {
|
|
62
|
+
const provider = providerRegistry.get(providerId);
|
|
63
|
+
if (provider && "ensureProjectPath" in provider) {
|
|
64
|
+
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const abortController = new AbortController();
|
|
69
|
+
const entryRef = activeSessions.get(sessionId);
|
|
70
|
+
if (entryRef) entryRef.abort = abortController;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
for await (const event of chatService.sendMessage(
|
|
74
|
+
providerId,
|
|
75
|
+
sessionId,
|
|
76
|
+
parsed.content,
|
|
77
|
+
)) {
|
|
78
|
+
if (abortController.signal.aborted) break;
|
|
79
|
+
ws.send(JSON.stringify(event));
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (!abortController.signal.aborted) {
|
|
83
|
+
ws.send(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
type: "error",
|
|
86
|
+
message: (e as Error).message,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
if (entryRef) entryRef.abort = undefined;
|
|
92
|
+
}
|
|
93
|
+
} else if (parsed.type === "cancel") {
|
|
94
|
+
// Only abort the underlying SDK query — don't break the for-await loop.
|
|
95
|
+
// This lets Claude send its final message before the iterator ends naturally.
|
|
96
|
+
const provider = providerRegistry.get(providerId);
|
|
97
|
+
if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
|
|
98
|
+
(provider as any).abortQuery(sessionId);
|
|
99
|
+
}
|
|
100
|
+
} else if (parsed.type === "approval_response") {
|
|
101
|
+
// Route approval response to the provider
|
|
102
|
+
const provider = providerRegistry.get(providerId);
|
|
103
|
+
if (provider && typeof provider.resolveApproval === "function") {
|
|
104
|
+
provider.resolveApproval(
|
|
105
|
+
parsed.requestId,
|
|
106
|
+
parsed.approved,
|
|
107
|
+
(parsed as any).data,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
close(ws: ChatWsSocket) {
|
|
114
|
+
const { sessionId } = ws.data;
|
|
115
|
+
const entry = activeSessions.get(sessionId);
|
|
116
|
+
if (entry) {
|
|
117
|
+
// Force-break the for-await loop — no client to receive events anymore
|
|
118
|
+
if (entry.abort) {
|
|
119
|
+
entry.abort.abort();
|
|
120
|
+
entry.abort = undefined;
|
|
121
|
+
}
|
|
122
|
+
// Also abort the underlying SDK query so Claude stops working
|
|
123
|
+
const provider = providerRegistry.get(entry.providerId);
|
|
124
|
+
if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
|
|
125
|
+
(provider as any).abortQuery(sessionId);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
activeSessions.delete(sessionId);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { terminalService } from "../../services/terminal.service.ts";
|
|
2
|
+
import { resolveProjectPath } from "../helpers/resolve-project.ts";
|
|
3
|
+
|
|
4
|
+
/** Control message prefix for resize commands */
|
|
5
|
+
const RESIZE_PREFIX = "\x01RESIZE:";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* WebSocket handler configuration for Bun.serve().
|
|
9
|
+
* Handles terminal session attach, input, resize, and disconnect.
|
|
10
|
+
*/
|
|
11
|
+
export const terminalWebSocket = {
|
|
12
|
+
open(ws: { data: { type: string; id: string; projectName?: string }; send: (data: string) => void }) {
|
|
13
|
+
const { id, projectName } = ws.data;
|
|
14
|
+
|
|
15
|
+
let session = id !== "new" ? terminalService.get(id) : undefined;
|
|
16
|
+
|
|
17
|
+
// If session doesn't exist and projectName is provided, create one
|
|
18
|
+
if (!session && projectName) {
|
|
19
|
+
try {
|
|
20
|
+
const projectPath = resolveProjectPath(projectName);
|
|
21
|
+
// Create session with the requested ID — but TerminalService generates its own ID.
|
|
22
|
+
// Instead, create and return the new session ID to client.
|
|
23
|
+
const newId = terminalService.create(projectPath);
|
|
24
|
+
session = terminalService.get(newId);
|
|
25
|
+
if (session) {
|
|
26
|
+
// Update ws.data to reflect actual session ID
|
|
27
|
+
ws.data.id = newId;
|
|
28
|
+
}
|
|
29
|
+
} catch (e) {
|
|
30
|
+
ws.send(JSON.stringify({ type: "error", message: (e as Error).message }));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!session) {
|
|
36
|
+
ws.send(JSON.stringify({ type: "error", message: "Session not found" }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sessionId = ws.data.id;
|
|
41
|
+
|
|
42
|
+
// Mark connected
|
|
43
|
+
terminalService.setConnected(sessionId, ws);
|
|
44
|
+
|
|
45
|
+
// Send session info
|
|
46
|
+
ws.send(JSON.stringify({ type: "session", id: sessionId }));
|
|
47
|
+
|
|
48
|
+
// Send buffered output for reconnect
|
|
49
|
+
const buffer = terminalService.getBuffer(sessionId);
|
|
50
|
+
if (buffer) {
|
|
51
|
+
ws.send(buffer);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Wire output listener
|
|
55
|
+
terminalService.onOutput(sessionId, (_id, data) => {
|
|
56
|
+
try {
|
|
57
|
+
ws.send(data);
|
|
58
|
+
} catch {
|
|
59
|
+
// WS closed
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
message(
|
|
65
|
+
ws: { data: { type: string; id: string } },
|
|
66
|
+
msg: string | ArrayBuffer | Uint8Array,
|
|
67
|
+
) {
|
|
68
|
+
const sessionId = ws.data.id;
|
|
69
|
+
const text = typeof msg === "string" ? msg : new TextDecoder().decode(msg as ArrayBuffer);
|
|
70
|
+
|
|
71
|
+
// Check for resize control message
|
|
72
|
+
if (text.startsWith(RESIZE_PREFIX)) {
|
|
73
|
+
const parts = text.slice(RESIZE_PREFIX.length).split(",");
|
|
74
|
+
const cols = parseInt(parts[0] ?? "80", 10);
|
|
75
|
+
const rows = parseInt(parts[1] ?? "24", 10);
|
|
76
|
+
terminalService.resize(sessionId, cols, rows);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Regular input — write to PTY
|
|
81
|
+
terminalService.write(sessionId, text);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
close(ws: { data: { type: string; id: string } }) {
|
|
85
|
+
const sessionId = ws.data.id;
|
|
86
|
+
terminalService.removeOutputListener(sessionId);
|
|
87
|
+
terminalService.setDisconnected(sessionId);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { providerRegistry } from "../providers/registry.ts";
|
|
2
|
+
import type {
|
|
3
|
+
Session,
|
|
4
|
+
SessionConfig,
|
|
5
|
+
SessionInfo,
|
|
6
|
+
ChatEvent,
|
|
7
|
+
ChatMessage,
|
|
8
|
+
} from "../providers/provider.interface.ts";
|
|
9
|
+
import { MockProvider } from "../providers/mock-provider.ts";
|
|
10
|
+
|
|
11
|
+
class ChatService {
|
|
12
|
+
async createSession(
|
|
13
|
+
providerId?: string,
|
|
14
|
+
config: SessionConfig = {},
|
|
15
|
+
): Promise<Session> {
|
|
16
|
+
const provider = providerId
|
|
17
|
+
? providerRegistry.get(providerId)
|
|
18
|
+
: providerRegistry.getDefault();
|
|
19
|
+
if (!provider) throw new Error(`Provider "${providerId}" not found`);
|
|
20
|
+
return provider.createSession(config);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async resumeSession(
|
|
24
|
+
providerId: string,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
): Promise<Session> {
|
|
27
|
+
const provider = providerRegistry.get(providerId);
|
|
28
|
+
if (!provider) throw new Error(`Provider "${providerId}" not found`);
|
|
29
|
+
return provider.resumeSession(sessionId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async listSessions(providerId?: string, dir?: string): Promise<SessionInfo[]> {
|
|
33
|
+
if (providerId) {
|
|
34
|
+
const provider = providerRegistry.get(providerId);
|
|
35
|
+
if (!provider) throw new Error(`Provider "${providerId}" not found`);
|
|
36
|
+
// Pass dir to providers that support it (SDK provider)
|
|
37
|
+
if (dir && "listSessionsByDir" in provider) {
|
|
38
|
+
return (provider as any).listSessionsByDir(dir);
|
|
39
|
+
}
|
|
40
|
+
return provider.listSessions();
|
|
41
|
+
}
|
|
42
|
+
// Aggregate from all providers
|
|
43
|
+
const all: SessionInfo[] = [];
|
|
44
|
+
for (const info of providerRegistry.list()) {
|
|
45
|
+
const provider = providerRegistry.get(info.id);
|
|
46
|
+
if (provider) {
|
|
47
|
+
if (dir && "listSessionsByDir" in provider) {
|
|
48
|
+
all.push(...await (provider as any).listSessionsByDir(dir));
|
|
49
|
+
} else {
|
|
50
|
+
all.push(...await provider.listSessions());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return all.sort(
|
|
55
|
+
(a, b) =>
|
|
56
|
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async deleteSession(
|
|
61
|
+
providerId: string,
|
|
62
|
+
sessionId: string,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const provider = providerRegistry.get(providerId);
|
|
65
|
+
if (!provider) throw new Error(`Provider "${providerId}" not found`);
|
|
66
|
+
return provider.deleteSession(sessionId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async *sendMessage(
|
|
70
|
+
providerId: string,
|
|
71
|
+
sessionId: string,
|
|
72
|
+
message: string,
|
|
73
|
+
): AsyncIterable<ChatEvent> {
|
|
74
|
+
const provider = providerRegistry.get(providerId);
|
|
75
|
+
if (!provider) {
|
|
76
|
+
yield { type: "error", message: `Provider "${providerId}" not found` };
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
yield* provider.sendMessage(sessionId, message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Look up a session across all providers (for WS handler) */
|
|
83
|
+
getSession(sessionId: string): Session | null {
|
|
84
|
+
for (const info of providerRegistry.list()) {
|
|
85
|
+
const provider = providerRegistry.get(info.id);
|
|
86
|
+
if (!provider) continue;
|
|
87
|
+
const sessions = (provider as any).sessions as Map<string, unknown> | undefined;
|
|
88
|
+
if (sessions?.has(sessionId)) {
|
|
89
|
+
const entry = sessions.get(sessionId);
|
|
90
|
+
// SDK provider stores {meta, sdk}, others store Session directly
|
|
91
|
+
if (entry && typeof entry === "object" && "meta" in entry) {
|
|
92
|
+
return (entry as { meta: Session }).meta;
|
|
93
|
+
}
|
|
94
|
+
return entry as Session ?? null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getMessages(providerId: string, sessionId: string): Promise<ChatMessage[]> {
|
|
101
|
+
const provider = providerRegistry.get(providerId);
|
|
102
|
+
if (!provider) return [];
|
|
103
|
+
if ("getMessages" in provider && typeof (provider as any).getMessages === "function") {
|
|
104
|
+
return await (provider as any).getMessages(sessionId);
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const chatService = new ChatService();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve, dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface LimitBucket {
|
|
6
|
+
utilization: number;
|
|
7
|
+
budgetPace: number;
|
|
8
|
+
resetsAt: string;
|
|
9
|
+
resetsInMinutes: number | null;
|
|
10
|
+
resetsInHours: number | null;
|
|
11
|
+
windowHours: number;
|
|
12
|
+
status: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ClaudeUsage {
|
|
16
|
+
timestamp?: string;
|
|
17
|
+
session?: LimitBucket;
|
|
18
|
+
weekly?: LimitBucket;
|
|
19
|
+
weeklyOpus?: LimitBucket;
|
|
20
|
+
weeklySonnet?: LimitBucket;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Cache to avoid spawning ccburn too often */
|
|
24
|
+
let cache: { data: ClaudeUsage; timestamp: number } | null = null;
|
|
25
|
+
const CACHE_TTL = 30_000; // 30 seconds
|
|
26
|
+
|
|
27
|
+
/** Cached resolved path */
|
|
28
|
+
let ccburnBin: string | undefined;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve ccburn binary. Checks:
|
|
32
|
+
* 1. node_modules/.bin/ccburn (relative to cwd — works for dev & prod)
|
|
33
|
+
* 2. Sibling to compiled binary (dist/node_modules/.bin/ccburn)
|
|
34
|
+
* 3. import.meta.dir based resolution
|
|
35
|
+
*/
|
|
36
|
+
function getCcburnPath(): string {
|
|
37
|
+
if (ccburnBin) return ccburnBin;
|
|
38
|
+
const candidates = [
|
|
39
|
+
resolve(process.cwd(), "node_modules/.bin/ccburn"),
|
|
40
|
+
resolve(dirname(process.argv[1] ?? ""), "../node_modules/.bin/ccburn"),
|
|
41
|
+
resolve(dirname(process.argv[1] ?? ""), "node_modules/.bin/ccburn"),
|
|
42
|
+
];
|
|
43
|
+
for (const p of candidates) {
|
|
44
|
+
if (existsSync(p)) {
|
|
45
|
+
ccburnBin = p;
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw new Error("ccburn not found — run: bun install");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch current usage/rate-limit info via ccburn (bundled dependency).
|
|
54
|
+
* ccburn handles credential retrieval (Keychain on macOS, etc.)
|
|
55
|
+
* and calls Anthropic's internal usage endpoint.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchClaudeUsage(): Promise<ClaudeUsage> {
|
|
58
|
+
if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
|
|
59
|
+
return cache.data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const bin = getCcburnPath();
|
|
64
|
+
const raw = execFileSync(bin, ["--json"], {
|
|
65
|
+
encoding: "utf-8",
|
|
66
|
+
timeout: 10_000,
|
|
67
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
+
}).trim();
|
|
69
|
+
|
|
70
|
+
// Extract JSON (skip any warnings before it)
|
|
71
|
+
const jsonStart = raw.indexOf("{");
|
|
72
|
+
const jsonStr = jsonStart > 0 ? raw.slice(jsonStart) : raw;
|
|
73
|
+
if (!jsonStr) return cache?.data ?? {};
|
|
74
|
+
|
|
75
|
+
const json = JSON.parse(jsonStr) as Record<string, unknown>;
|
|
76
|
+
const limits = json.limits as Record<string, unknown> | undefined;
|
|
77
|
+
if (!limits) return cache?.data ?? {};
|
|
78
|
+
|
|
79
|
+
const data: ClaudeUsage = {
|
|
80
|
+
timestamp: json.timestamp as string | undefined,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (limits.session && typeof limits.session === "object") {
|
|
84
|
+
data.session = parseBucket(limits.session as Record<string, unknown>);
|
|
85
|
+
}
|
|
86
|
+
if (limits.weekly && typeof limits.weekly === "object") {
|
|
87
|
+
data.weekly = parseBucket(limits.weekly as Record<string, unknown>);
|
|
88
|
+
}
|
|
89
|
+
if (limits.weekly_opus && typeof limits.weekly_opus === "object") {
|
|
90
|
+
data.weeklyOpus = parseBucket(limits.weekly_opus as Record<string, unknown>);
|
|
91
|
+
}
|
|
92
|
+
if (limits.weekly_sonnet && typeof limits.weekly_sonnet === "object") {
|
|
93
|
+
data.weeklySonnet = parseBucket(limits.weekly_sonnet as Record<string, unknown>);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
cache = { data, timestamp: Date.now() };
|
|
97
|
+
return data;
|
|
98
|
+
} catch {
|
|
99
|
+
return cache?.data ?? {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseBucket(raw: Record<string, unknown>): LimitBucket {
|
|
104
|
+
return {
|
|
105
|
+
utilization: (raw.utilization as number) ?? 0,
|
|
106
|
+
budgetPace: (raw.budget_pace as number) ?? 0,
|
|
107
|
+
resetsAt: (raw.resets_at as string) ?? "",
|
|
108
|
+
resetsInMinutes: (raw.resets_in_minutes as number) ?? null,
|
|
109
|
+
resetsInHours: (raw.resets_in_hours as number) ?? null,
|
|
110
|
+
windowHours: (raw.window_hours as number) ?? 0,
|
|
111
|
+
status: (raw.status as string) ?? "",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import type { PpmConfig } from "../types/config.ts";
|
|
7
|
+
import { DEFAULT_CONFIG } from "../types/config.ts";
|
|
8
|
+
|
|
9
|
+
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
10
|
+
const GLOBAL_CONFIG_PATH = resolve(PPM_DIR, "config.yaml");
|
|
11
|
+
const LOCAL_CONFIG_PATH = resolve(process.cwd(), "ppm.yaml");
|
|
12
|
+
|
|
13
|
+
class ConfigService {
|
|
14
|
+
private config: PpmConfig = structuredClone(DEFAULT_CONFIG);
|
|
15
|
+
private configPath: string = GLOBAL_CONFIG_PATH;
|
|
16
|
+
|
|
17
|
+
/** Load config from: explicit path → ./ppm.yaml → ~/.ppm/config.yaml */
|
|
18
|
+
load(explicitPath?: string): PpmConfig {
|
|
19
|
+
const searchPaths = [
|
|
20
|
+
explicitPath,
|
|
21
|
+
LOCAL_CONFIG_PATH,
|
|
22
|
+
GLOBAL_CONFIG_PATH,
|
|
23
|
+
].filter(Boolean) as string[];
|
|
24
|
+
|
|
25
|
+
for (const p of searchPaths) {
|
|
26
|
+
if (existsSync(p)) {
|
|
27
|
+
const raw = readFileSync(p, "utf-8");
|
|
28
|
+
const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
|
|
29
|
+
if (parsed) {
|
|
30
|
+
this.config = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
|
|
31
|
+
this.configPath = p;
|
|
32
|
+
// Auto-generate token if auth enabled but token is empty
|
|
33
|
+
if (this.config.auth.enabled && !this.config.auth.token) {
|
|
34
|
+
this.config.auth.token = randomBytes(16).toString("hex");
|
|
35
|
+
this.save();
|
|
36
|
+
}
|
|
37
|
+
return this.config;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// No config found — create default
|
|
43
|
+
this.config = this.createDefault();
|
|
44
|
+
return this.config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Save current config to disk */
|
|
48
|
+
save(): void {
|
|
49
|
+
const dir = dirname(this.configPath);
|
|
50
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
51
|
+
writeFileSync(this.configPath, yaml.dump(this.config), "utf-8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Get a top-level config key */
|
|
55
|
+
get<K extends keyof PpmConfig>(key: K): PpmConfig[K] {
|
|
56
|
+
return this.config[key];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Set a top-level config key */
|
|
60
|
+
set<K extends keyof PpmConfig>(key: K, value: PpmConfig[K]): void {
|
|
61
|
+
this.config[key] = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Get the full config object */
|
|
65
|
+
getAll(): PpmConfig {
|
|
66
|
+
return this.config;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get the path config was loaded from */
|
|
70
|
+
getConfigPath(): string {
|
|
71
|
+
return this.configPath;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Set the config path explicitly (for init command) */
|
|
75
|
+
setConfigPath(p: string): void {
|
|
76
|
+
this.configPath = p;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Create default config with auto-generated auth token */
|
|
80
|
+
private createDefault(): PpmConfig {
|
|
81
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
82
|
+
config.auth.token = randomBytes(16).toString("hex");
|
|
83
|
+
this.configPath = GLOBAL_CONFIG_PATH;
|
|
84
|
+
this.save();
|
|
85
|
+
return config;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Singleton config service */
|
|
90
|
+
export const configService = new ConfigService();
|