@hienlh/ppm 0.13.85 → 0.13.88
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 +21 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +2248 -0
- package/bunfig.toml +2 -0
- package/dist/web/assets/{audio-preview-IRQhYBc9.js → audio-preview-CUIJFOwv.js} +1 -1
- package/dist/web/assets/chat-tab-Bq1I6z2m.js +16 -0
- package/dist/web/assets/{code-editor-N8XfT4X6.js → code-editor-DJwyZl0r.js} +2 -2
- package/dist/web/assets/{conflict-editor-D8_hVdKW.js → conflict-editor-PGKAsN-h.js} +1 -1
- package/dist/web/assets/{database-viewer-8FKzLz3e.js → database-viewer-DAqD73p3.js} +1 -1
- package/dist/web/assets/{diff-viewer-BSYOjjem.js → diff-viewer-DRlDATQT.js} +1 -1
- package/dist/web/assets/{docx-preview-CslyLNXJ.js → docx-preview-DbaPlCqB.js} +1 -1
- package/dist/web/assets/{extension-webview-C7Z_LmqG.js → extension-webview-DMj9G5Po.js} +1 -1
- package/dist/web/assets/{git-log-panel-BtIbMKyY.js → git-log-panel-Dy6gNl-B.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Di9XehtO.js → glide-data-grid-BbnDo-8v.js} +1 -1
- package/dist/web/assets/{image-preview-DgxEXvm1.js → image-preview-BS_AtRhx.js} +1 -1
- package/dist/web/assets/index-D-rGwbQ3.css +2 -0
- package/dist/web/assets/{index-Co68v-VL.js → index-Z19QnKM_.js} +4 -4
- package/dist/web/assets/keybindings-store-BRyZRYax.js +1 -0
- package/dist/web/assets/{markdown-renderer-Cc8feZvH.js → markdown-renderer-D-tteKCI.js} +1 -1
- package/dist/web/assets/notification-store-gHx4anzy.js +1 -0
- package/dist/web/assets/{pdf-preview-2Kkq0YVS.js → pdf-preview-B4iy8M0h.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-Dm06Sss7.js → port-forwarding-tab-CNHUZvG5.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Dk-JGd_M.js → postgres-viewer-USxLRNuo.js} +1 -1
- package/dist/web/assets/{settings-tab-BMbf1_zH.js → settings-tab-DuhQTnn5.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BgiCruQe.js → sql-query-editor-DFCopLXf.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DIQna_ds.js → sqlite-viewer-Dmztw-o5.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-C3v0u_y6.js → system-monitor-tab-OkICyNSb.js} +1 -1
- package/dist/web/assets/{terminal-tab-B7E6qSYt.js → terminal-tab-Dsbs_Tnl.js} +1 -1
- package/dist/web/assets/{video-preview--2wXKPqj.js → video-preview-Dr00uBM7.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/schemas/ppm-config.schema.json +2 -2
- package/src/cli/commands/init.ts +2 -1
- package/src/index.ts +0 -0
- package/src/providers/claude-agent-sdk.ts +5 -4
- package/src/server/ws/chat.ts +55 -4
- package/src/services/db.service.ts +17 -0
- package/src/services/supervisor.ts +11 -4
- package/src/services/tunnel.service.ts +1 -1
- package/src/types/api.ts +3 -2
- package/src/types/chat.ts +6 -0
- package/src/types/config.ts +1 -1
- package/src/web/components/chat/chat-tab.tsx +4 -0
- package/src/web/components/chat/message-input.tsx +25 -0
- package/src/web/components/chat/model-selector.tsx +138 -0
- package/src/web/components/settings/proxy-test-section.tsx +1 -0
- package/src/web/hooks/use-chat.ts +19 -0
- package/dist/web/assets/chat-tab-CrI_JDRj.js +0 -16
- package/dist/web/assets/index-DXxsPKPw.css +0 -2
- package/dist/web/assets/keybindings-store-COecqk_y.js +0 -1
- package/dist/web/assets/notification-store-B5j-Cm2O.js +0 -1
|
@@ -284,7 +284,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
284
284
|
// SDK uses `errors: string[]` array for error details
|
|
285
285
|
const errorsArr = Array.isArray(e.errors) ? (e.errors as string[]).join(" ") : "";
|
|
286
286
|
const msg = errorsArr || String(e.error ?? "");
|
|
287
|
-
if (msg.includes("429") || msg.toLowerCase().includes("rate limit") || msg.toLowerCase().includes("overloaded") ||
|
|
287
|
+
if (msg.includes("429") || msg.toLowerCase().includes("rate limit") || msg.toLowerCase().includes("overloaded") || /hit your (?:[\w-]+\s+)*limit/i.test(msg)) return 429;
|
|
288
288
|
if (msg.includes("401") || msg.toLowerCase().includes("unauthorized") || msg.toLowerCase().includes("invalid api key")) return 401;
|
|
289
289
|
}
|
|
290
290
|
return null;
|
|
@@ -518,6 +518,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
518
518
|
async listModels(): Promise<ModelOption[]> {
|
|
519
519
|
return [
|
|
520
520
|
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
521
|
+
{ value: "claude-opus-4-8", label: "Claude Opus 4.8" },
|
|
521
522
|
{ value: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
|
522
523
|
{ value: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
|
523
524
|
{ value: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
|
@@ -848,7 +849,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
848
849
|
...(hasMcp && { mcpServers }),
|
|
849
850
|
permissionMode,
|
|
850
851
|
allowDangerouslySkipPermissions: isBypass,
|
|
851
|
-
...(providerConfig.model && { model: providerConfig.model }),
|
|
852
|
+
...((opts?.model ?? providerConfig.model) && { model: opts?.model ?? providerConfig.model }),
|
|
852
853
|
...(providerConfig.effort && { effort: providerConfig.effort }),
|
|
853
854
|
maxTurns: providerConfig.max_turns ?? 1000,
|
|
854
855
|
...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
|
|
@@ -1167,7 +1168,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1167
1168
|
if (textContent && /API Error:\s*401\b.*authentication_error/i.test(textContent)) {
|
|
1168
1169
|
assistantError = "authentication_failed";
|
|
1169
1170
|
console.warn(`[sdk] session=${sessionId} detected 401 in assistant text content — treating as auth error`);
|
|
1170
|
-
} else if (textContent && /hit your limit
|
|
1171
|
+
} else if (textContent && /hit your (?:[\w-]+\s+)*limit/i.test(textContent)) {
|
|
1171
1172
|
assistantError = "rate_limit";
|
|
1172
1173
|
console.warn(`[sdk] session=${sessionId} detected quota limit in assistant text content — treating as rate_limit`);
|
|
1173
1174
|
}
|
|
@@ -1448,7 +1449,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1448
1449
|
hint = "\n\nHint: Network connectivity issue. Check your internet connection and firewall/proxy settings.";
|
|
1449
1450
|
} else if (detailLower.includes("401") || detailLower.includes("unauthorized") || detailLower.includes("invalid api key")) {
|
|
1450
1451
|
hint = "\n\nHint: Authentication failed. Try re-adding your account in Settings → Accounts.";
|
|
1451
|
-
} else if (
|
|
1452
|
+
} else if (/hit your (?:[\w-]+\s+)*limit/i.test(detailLower)) {
|
|
1452
1453
|
hint = "\n\nHint: Account quota exhausted. Will auto-switch on next message if other accounts are available.";
|
|
1453
1454
|
}
|
|
1454
1455
|
const fullMsg = sdkDetail ? `${baseMsg}\n${sdkDetail}${hint}` : baseMsg;
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -3,10 +3,20 @@ import { providerRegistry } from "../../providers/registry.ts";
|
|
|
3
3
|
import { resolveProjectPath } from "../helpers/resolve-project.ts";
|
|
4
4
|
import { logSessionEvent } from "../../services/session-log.service.ts";
|
|
5
5
|
import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
-
import { getSessionTitle, incrementSessionUnread, clearSessionUnread } from "../../services/db.service.ts";
|
|
6
|
+
import { getSessionTitle, incrementSessionUnread, clearSessionUnread, getSessionModel, setSessionModel } from "../../services/db.service.ts";
|
|
7
7
|
import type { ChatWsClientMessage, SessionPhase } from "../../types/api.ts";
|
|
8
8
|
import { startWatching, stopWatching, onFileChange } from "../../services/file-watcher.service.ts";
|
|
9
9
|
import { bashOutputSpy } from "../../services/bash-output-spy.ts";
|
|
10
|
+
import { configService } from "../../services/config.service.ts";
|
|
11
|
+
|
|
12
|
+
/** Resolve the model shown in session_state: per-session override, else provider default. */
|
|
13
|
+
function resolveSessionModel(sessionId: string): string | undefined {
|
|
14
|
+
const override = getSessionModel(sessionId);
|
|
15
|
+
if (override) return override;
|
|
16
|
+
const ai = configService.get("ai");
|
|
17
|
+
const pid = ai.default_provider ?? "claude";
|
|
18
|
+
return ai.providers[pid]?.model;
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
// Broadcast file changes to all WS clients for real-time editor reload
|
|
12
22
|
onFileChange((projectName, path) => {
|
|
@@ -42,6 +52,8 @@ interface SessionEntry {
|
|
|
42
52
|
currentUserMessage?: string;
|
|
43
53
|
streamPromise?: Promise<void>;
|
|
44
54
|
permissionMode?: string;
|
|
55
|
+
/** Per-session model override; falls back to provider default when undefined */
|
|
56
|
+
model?: string;
|
|
45
57
|
/** Whether the persistent event consumer loop is running */
|
|
46
58
|
isStreamingActive: boolean;
|
|
47
59
|
/** Active team watchers keyed by team name */
|
|
@@ -203,7 +215,7 @@ function startCleanupTimer(sessionId: string): void {
|
|
|
203
215
|
* First message creates the query; follow-ups push into the provider's
|
|
204
216
|
* message channel. Events from ALL turns flow through this single loop.
|
|
205
217
|
*/
|
|
206
|
-
async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }
|
|
218
|
+
async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>, model?: string): Promise<void> {
|
|
207
219
|
const entry = activeSessions.get(sessionId);
|
|
208
220
|
if (!entry) {
|
|
209
221
|
console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
|
|
@@ -255,7 +267,7 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
255
267
|
broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
|
|
256
268
|
}, 5_000);
|
|
257
269
|
|
|
258
|
-
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
|
|
270
|
+
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images, ...(model && { model }) })) {
|
|
259
271
|
eventCount++;
|
|
260
272
|
const ev = event as any;
|
|
261
273
|
const evType = ev.type ?? "unknown";
|
|
@@ -520,6 +532,7 @@ export const chatWebSocket = {
|
|
|
520
532
|
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
521
533
|
sessionTitle: session?.title || null,
|
|
522
534
|
compactStatus: existing.compactStatus ?? null,
|
|
535
|
+
model: resolveSessionModel(sessionId),
|
|
523
536
|
}));
|
|
524
537
|
|
|
525
538
|
// If actively streaming, send buffered turn events for reconnect sync
|
|
@@ -561,6 +574,7 @@ export const chatWebSocket = {
|
|
|
561
574
|
teamWatchers: new Map(),
|
|
562
575
|
teamNames: new Set(),
|
|
563
576
|
compactStatus: null,
|
|
577
|
+
model: getSessionModel(sessionId) ?? undefined,
|
|
564
578
|
};
|
|
565
579
|
activeSessions.set(sessionId, newEntry);
|
|
566
580
|
setupClientPing(newEntry, ws);
|
|
@@ -573,6 +587,7 @@ export const chatWebSocket = {
|
|
|
573
587
|
pendingApproval: null,
|
|
574
588
|
sessionTitle: session?.title || null,
|
|
575
589
|
compactStatus: null,
|
|
590
|
+
model: resolveSessionModel(sessionId),
|
|
576
591
|
}));
|
|
577
592
|
|
|
578
593
|
// Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
|
|
@@ -615,6 +630,7 @@ export const chatWebSocket = {
|
|
|
615
630
|
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
616
631
|
pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
|
|
617
632
|
teamWatchers: new Map(), teamNames: new Set(), compactStatus: null,
|
|
633
|
+
model: getSessionModel(sessionId) ?? undefined,
|
|
618
634
|
};
|
|
619
635
|
activeSessions.set(sessionId, newEntry);
|
|
620
636
|
setupClientPing(newEntry, ws);
|
|
@@ -640,6 +656,7 @@ export const chatWebSocket = {
|
|
|
640
656
|
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
641
657
|
sessionTitle: chatService.getSession(sessionId)?.title || null,
|
|
642
658
|
compactStatus: entry.compactStatus ?? null,
|
|
659
|
+
model: resolveSessionModel(sessionId),
|
|
643
660
|
}));
|
|
644
661
|
if (entry.phase !== "idle") {
|
|
645
662
|
sendTurnEvents(sessionId, ws);
|
|
@@ -675,6 +692,11 @@ export const chatWebSocket = {
|
|
|
675
692
|
if (parsed.permissionMode) {
|
|
676
693
|
entry.permissionMode = parsed.permissionMode;
|
|
677
694
|
}
|
|
695
|
+
// Store model override — sticky for this session
|
|
696
|
+
if (parsed.model) {
|
|
697
|
+
entry.model = parsed.model;
|
|
698
|
+
setSessionModel(sessionId, parsed.model);
|
|
699
|
+
}
|
|
678
700
|
|
|
679
701
|
// Intercept PPM-handled built-in commands (e.g. /skills, /version)
|
|
680
702
|
const content = parsed.content.trim();
|
|
@@ -717,10 +739,11 @@ export const chatWebSocket = {
|
|
|
717
739
|
setPhase(sessionId, "initializing");
|
|
718
740
|
|
|
719
741
|
const permMode = entry.permissionMode;
|
|
742
|
+
const msgModel = entry.model;
|
|
720
743
|
const msgImages = parsed.type === "message" ? parsed.images : undefined;
|
|
721
744
|
entry.streamPromise = new Promise<void>((resolve) => {
|
|
722
745
|
setTimeout(() => {
|
|
723
|
-
startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
|
|
746
|
+
startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages, msgModel).then(resolve, resolve);
|
|
724
747
|
}, 0);
|
|
725
748
|
});
|
|
726
749
|
} else {
|
|
@@ -737,6 +760,34 @@ export const chatWebSocket = {
|
|
|
737
760
|
setPhase(sessionId, "thinking");
|
|
738
761
|
console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
|
|
739
762
|
}
|
|
763
|
+
} else if (parsed.type === "set_model") {
|
|
764
|
+
// Persist per-session model override. If an idle subprocess is alive,
|
|
765
|
+
// abort it so the next message recreates the query with the new model
|
|
766
|
+
// (history preserved via the resume path). No-op if already streaming.
|
|
767
|
+
if (!parsed.model || typeof parsed.model !== "string") {
|
|
768
|
+
ws.send(JSON.stringify({ type: "error", message: "model is required" }));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
entry.model = parsed.model;
|
|
772
|
+
setSessionModel(sessionId, parsed.model);
|
|
773
|
+
const provider = providerRegistry.get(providerId);
|
|
774
|
+
const hasLiveStream = provider?.hasStreamingSession?.(sessionId) ?? false;
|
|
775
|
+
// Only abort when idle between turns — never interrupt an active turn.
|
|
776
|
+
// Aborting the idle-but-alive subprocess forces the next message to take
|
|
777
|
+
// the resume path, recreating the query with the new model.
|
|
778
|
+
if (hasLiveStream && entry.phase === "idle") {
|
|
779
|
+
provider?.abortQuery?.(sessionId, "set_model");
|
|
780
|
+
}
|
|
781
|
+
logSessionEvent(sessionId, "INFO", `Model switched to ${parsed.model}`);
|
|
782
|
+
ws.send(JSON.stringify({
|
|
783
|
+
type: "session_state",
|
|
784
|
+
sessionId,
|
|
785
|
+
phase: entry.phase,
|
|
786
|
+
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
787
|
+
sessionTitle: chatService.getSession(sessionId)?.title || null,
|
|
788
|
+
compactStatus: entry.compactStatus ?? null,
|
|
789
|
+
model: resolveSessionModel(sessionId),
|
|
790
|
+
}));
|
|
740
791
|
} else if (parsed.type === "cancel") {
|
|
741
792
|
// Fully teardown streaming session — user must resume to continue
|
|
742
793
|
const provider = providerRegistry.get(providerId);
|
|
@@ -645,6 +645,11 @@ function runMigrations(database: Database): void {
|
|
|
645
645
|
PRAGMA user_version = 26;
|
|
646
646
|
`);
|
|
647
647
|
}
|
|
648
|
+
|
|
649
|
+
if (current < 27) {
|
|
650
|
+
try { database.exec("ALTER TABLE session_metadata ADD COLUMN model TEXT"); } catch {}
|
|
651
|
+
database.exec("PRAGMA user_version = 27;");
|
|
652
|
+
}
|
|
648
653
|
}
|
|
649
654
|
|
|
650
655
|
// ---------------------------------------------------------------------------
|
|
@@ -801,6 +806,18 @@ export function deleteSessionMetadata(sessionId: string): void {
|
|
|
801
806
|
getDb().query("DELETE FROM session_metadata WHERE session_id = ?").run(sessionId);
|
|
802
807
|
}
|
|
803
808
|
|
|
809
|
+
/** Per-session model override; null when session uses provider default */
|
|
810
|
+
export function getSessionModel(sessionId: string): string | null {
|
|
811
|
+
const row = getDb().query("SELECT model FROM session_metadata WHERE session_id = ?").get(sessionId) as { model: string | null } | null;
|
|
812
|
+
return row?.model ?? null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export function setSessionModel(sessionId: string, model: string): void {
|
|
816
|
+
getDb().query(
|
|
817
|
+
"INSERT INTO session_metadata (session_id, model) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET model = excluded.model",
|
|
818
|
+
).run(sessionId, model);
|
|
819
|
+
}
|
|
820
|
+
|
|
804
821
|
// ---------------------------------------------------------------------------
|
|
805
822
|
// Unread tracking
|
|
806
823
|
// ---------------------------------------------------------------------------
|
|
@@ -25,12 +25,13 @@ import { sdNotify } from "./sd-notify.ts";
|
|
|
25
25
|
const MAX_RESTARTS = 10;
|
|
26
26
|
const BACKOFF_BASE_MS = 1000;
|
|
27
27
|
const BACKOFF_MAX_MS = 60_000;
|
|
28
|
+
const TUNNEL_COOLDOWN_MS = 600_000; // 10min cooldown after MAX_RESTARTS before retrying tunnel
|
|
28
29
|
const STABLE_WINDOW_MS = 300_000; // 5min stable → reset restart counter
|
|
29
30
|
const SERVER_HEALTH_INTERVAL_MS = 30_000;
|
|
30
31
|
const SERVER_HEALTH_FAIL_THRESHOLD = 3;
|
|
31
32
|
const TUNNEL_PROBE_INTERVAL_MS = 30_000; // 30s — adopted tunnels have no `exited` promise
|
|
32
33
|
const TUNNEL_PROBE_FAIL_THRESHOLD = 3; // 3 HTTP failures before regenerating (PID check is instant)
|
|
33
|
-
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
34
|
+
const TUNNEL_URL_REGEX = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/;
|
|
34
35
|
const UPGRADE_CHECK_INTERVAL_MS = 900_000; // 15min
|
|
35
36
|
const UPGRADE_SKIP_INITIAL_MS = 300_000; // 5min delay before first check
|
|
36
37
|
const SELF_REPLACE_TIMEOUT_MS = 30_000; // 30s to wait for new supervisor
|
|
@@ -262,9 +263,12 @@ export async function spawnTunnel(port: number): Promise<void> {
|
|
|
262
263
|
tunnelRestarts++;
|
|
263
264
|
|
|
264
265
|
if (tunnelRestarts > MAX_RESTARTS) {
|
|
265
|
-
log("
|
|
266
|
+
log("WARN", `Tunnel exceeded ${MAX_RESTARTS} URL extraction failures, cooldown ${TUNNEL_COOLDOWN_MS}ms before retry`);
|
|
266
267
|
updateStatus({ shareUrl: null, tunnelPid: null });
|
|
267
|
-
|
|
268
|
+
await Bun.sleep(TUNNEL_COOLDOWN_MS);
|
|
269
|
+
tunnelRestarts = 0;
|
|
270
|
+
if (shuttingDown) return;
|
|
271
|
+
return spawnTunnel(port);
|
|
268
272
|
}
|
|
269
273
|
|
|
270
274
|
const delay = backoffDelay(tunnelRestarts);
|
|
@@ -293,8 +297,11 @@ export async function spawnTunnel(port: number): Promise<void> {
|
|
|
293
297
|
tunnelRestarts++;
|
|
294
298
|
|
|
295
299
|
if (tunnelRestarts > MAX_RESTARTS) {
|
|
296
|
-
log("
|
|
300
|
+
log("WARN", `Tunnel exceeded ${MAX_RESTARTS} restarts, cooldown ${TUNNEL_COOLDOWN_MS}ms before retry`);
|
|
297
301
|
updateStatus({ shareUrl: null, tunnelPid: null });
|
|
302
|
+
await Bun.sleep(TUNNEL_COOLDOWN_MS);
|
|
303
|
+
tunnelRestarts = 0;
|
|
304
|
+
if (!shuttingDown) return spawnTunnel(port);
|
|
298
305
|
return;
|
|
299
306
|
}
|
|
300
307
|
|
|
@@ -4,7 +4,7 @@ import { existsSync, unlinkSync, readFileSync, writeFileSync, renameSync } from
|
|
|
4
4
|
import { ensureCloudflared } from "./cloudflared.service.ts";
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
6
|
|
|
7
|
-
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
7
|
+
const TUNNEL_URL_REGEX = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/;
|
|
8
8
|
const decoder = new TextDecoder();
|
|
9
9
|
|
|
10
10
|
/** Extract tunnel URL from cloudflared stderr output */
|
package/src/types/api.ts
CHANGED
|
@@ -23,8 +23,9 @@ export type TerminalWsMessage =
|
|
|
23
23
|
|
|
24
24
|
/** WebSocket message types (chat) */
|
|
25
25
|
export type ChatWsClientMessage =
|
|
26
|
-
| { type: "message"; content: string; permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }
|
|
26
|
+
| { type: "message"; content: string; permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }>; model?: string }
|
|
27
27
|
| { type: "cancel" }
|
|
28
|
+
| { type: "set_model"; model: string }
|
|
28
29
|
| { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown }
|
|
29
30
|
| { type: "ready" };
|
|
30
31
|
|
|
@@ -42,7 +43,7 @@ export type ChatWsServerMessage =
|
|
|
42
43
|
| { type: "error"; message: string }
|
|
43
44
|
| { type: "account_info"; accountId: string; accountLabel: string }
|
|
44
45
|
| { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
|
|
45
|
-
| { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null }
|
|
46
|
+
| { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null; model?: string }
|
|
46
47
|
| { type: "turn_events"; events: unknown[] }
|
|
47
48
|
| { type: "title_updated"; title: string }
|
|
48
49
|
| { type: "compact_status"; status: "compacting" | "done" }
|
package/src/types/chat.ts
CHANGED
|
@@ -2,6 +2,8 @@ export interface SendMessageOpts {
|
|
|
2
2
|
permissionMode?: import("./config").PermissionMode | string;
|
|
3
3
|
priority?: 'now' | 'next' | 'later';
|
|
4
4
|
images?: Array<{ data: string; mediaType: string }>;
|
|
5
|
+
/** Per-session model override; falls back to provider config model when absent */
|
|
6
|
+
model?: string;
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
export interface AIProvider {
|
|
@@ -33,6 +35,8 @@ export interface AIProvider {
|
|
|
33
35
|
markAsResumed?(sessionId: string): void;
|
|
34
36
|
isAvailable?(): Promise<boolean>;
|
|
35
37
|
listModels?(): Promise<ModelOption[]>;
|
|
38
|
+
/** True when a live streaming subprocess exists for this session */
|
|
39
|
+
hasStreamingSession?(sessionId: string): boolean;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface ModelOption {
|
|
@@ -47,6 +51,8 @@ export interface Session {
|
|
|
47
51
|
projectName?: string;
|
|
48
52
|
projectPath?: string;
|
|
49
53
|
createdAt: string;
|
|
54
|
+
/** Per-session model override (e.g. claude-opus-4-8); falls back to provider config default */
|
|
55
|
+
model?: string;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export interface SessionConfig {
|
package/src/types/config.ts
CHANGED
|
@@ -104,7 +104,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
|
|
|
104
104
|
|
|
105
105
|
const VALID_TYPES = ["agent-sdk", "cli", "mock"] as const;
|
|
106
106
|
const VALID_EFFORTS = ["low", "medium", "high"] as const;
|
|
107
|
-
const VALID_MODELS = ["claude-sonnet-4-6", "claude-opus-4-7", "claude-opus-4-6", "claude-haiku-4-5"] as const;
|
|
107
|
+
const VALID_MODELS = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-haiku-4-5"] as const;
|
|
108
108
|
/** Allowed CLI commands for CLI providers (prevents command injection) */
|
|
109
109
|
const VALID_CLI_COMMANDS = ["cursor-agent", "codex", "gemini"] as const;
|
|
110
110
|
/** Only these values are allowed for default_provider in config */
|
|
@@ -106,6 +106,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
106
106
|
compactStatus,
|
|
107
107
|
statusMessage,
|
|
108
108
|
sessionTitle,
|
|
109
|
+
model,
|
|
110
|
+
setModel,
|
|
109
111
|
sendMessage,
|
|
110
112
|
respondToApproval,
|
|
111
113
|
cancelStreaming,
|
|
@@ -516,6 +518,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
516
518
|
onModeChange={setPermissionMode}
|
|
517
519
|
providerId={providerId}
|
|
518
520
|
onProviderChange={!sessionId ? setProviderId : undefined}
|
|
521
|
+
model={model}
|
|
522
|
+
onModelChange={setModel}
|
|
519
523
|
/>
|
|
520
524
|
)}
|
|
521
525
|
</div>
|
|
@@ -7,6 +7,7 @@ import { isImageFile } from "@/lib/file-support";
|
|
|
7
7
|
import { AttachmentChips } from "./attachment-chips";
|
|
8
8
|
import { ModeSelector, getModeLabel, getModeIcon } from "./mode-selector";
|
|
9
9
|
import { ProviderSelector } from "./provider-selector";
|
|
10
|
+
import { ModelSelector } from "./model-selector";
|
|
10
11
|
import type { SlashItem } from "./slash-command-picker";
|
|
11
12
|
import type { FileNode } from "../../../types/project";
|
|
12
13
|
import { useFileStore } from "@/stores/file-store";
|
|
@@ -62,6 +63,10 @@ interface MessageInputProps {
|
|
|
62
63
|
providerId?: string;
|
|
63
64
|
/** Provider change handler — undefined when session is active (locked) */
|
|
64
65
|
onProviderChange?: (providerId: string) => void;
|
|
66
|
+
/** Current per-session model (null = provider default) */
|
|
67
|
+
model?: string | null;
|
|
68
|
+
/** Model change handler — undefined when no active session */
|
|
69
|
+
onModelChange?: (model: string) => void;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
export const MessageInput = memo(function MessageInput({
|
|
@@ -86,6 +91,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
86
91
|
onModeChange,
|
|
87
92
|
providerId,
|
|
88
93
|
onProviderChange,
|
|
94
|
+
model,
|
|
95
|
+
onModelChange,
|
|
89
96
|
}: MessageInputProps) {
|
|
90
97
|
// Uncontrolled textarea: value lives in DOM + ref, not React state.
|
|
91
98
|
// Only `hasText` state triggers re-renders (empty↔non-empty for send button).
|
|
@@ -643,6 +650,15 @@ export const MessageInput = memo(function MessageInput({
|
|
|
643
650
|
projectName={projectName}
|
|
644
651
|
/>
|
|
645
652
|
)}
|
|
653
|
+
{onModelChange && projectName && (
|
|
654
|
+
<ModelSelector
|
|
655
|
+
value={model ?? null}
|
|
656
|
+
onChange={onModelChange}
|
|
657
|
+
projectName={projectName}
|
|
658
|
+
providerId={providerId ?? "claude"}
|
|
659
|
+
disabled={isStreaming}
|
|
660
|
+
/>
|
|
661
|
+
)}
|
|
646
662
|
{isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
|
|
647
663
|
</div>
|
|
648
664
|
{/* Mobile: single row — attach + textarea + mic + send */}
|
|
@@ -751,6 +767,15 @@ export const MessageInput = memo(function MessageInput({
|
|
|
751
767
|
projectName={projectName}
|
|
752
768
|
/>
|
|
753
769
|
)}
|
|
770
|
+
{onModelChange && projectName && (
|
|
771
|
+
<ModelSelector
|
|
772
|
+
value={model ?? null}
|
|
773
|
+
onChange={onModelChange}
|
|
774
|
+
projectName={projectName}
|
|
775
|
+
providerId={providerId ?? "claude"}
|
|
776
|
+
disabled={isStreaming}
|
|
777
|
+
/>
|
|
778
|
+
)}
|
|
754
779
|
{isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
|
|
755
780
|
</div>
|
|
756
781
|
<div className="flex items-center gap-1">
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, type KeyboardEvent } from "react";
|
|
2
|
+
import { Check, Sparkles } from "lucide-react";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
|
|
5
|
+
interface ModelOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ModelSelectorProps {
|
|
11
|
+
value: string | null;
|
|
12
|
+
onChange: (model: string) => void;
|
|
13
|
+
projectName: string;
|
|
14
|
+
providerId: string;
|
|
15
|
+
/** When true, the chip is shown but not interactive (e.g. while streaming) */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Strip the leading "Claude " so the chip stays compact: "Claude Opus 4.8" → "Opus 4.8" */
|
|
20
|
+
function shortLabel(label: string): string {
|
|
21
|
+
return label.replace(/^Claude\s+/i, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Model selector chip + popup — matches ProviderSelector style.
|
|
26
|
+
* Hidden when only 1 (or no) model is available.
|
|
27
|
+
* Interactive only when not disabled (model can't change mid-turn).
|
|
28
|
+
*/
|
|
29
|
+
export function ModelSelector({ value, onChange, projectName, providerId, disabled }: ModelSelectorProps) {
|
|
30
|
+
const [models, setModels] = useState<ModelOption[]>([]);
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const focusedRef = useRef(0);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!projectName || !providerId) return;
|
|
37
|
+
api.get<ModelOption[]>(`${projectUrl(projectName)}/chat/providers/${providerId}/models`)
|
|
38
|
+
.then(setModels)
|
|
39
|
+
.catch(() => {});
|
|
40
|
+
}, [projectName, providerId]);
|
|
41
|
+
|
|
42
|
+
// Close popup if it becomes disabled mid-open
|
|
43
|
+
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
|
|
44
|
+
|
|
45
|
+
// Close on click outside
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) return;
|
|
48
|
+
const handler = (e: MouseEvent) => {
|
|
49
|
+
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
50
|
+
setOpen(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener("mousedown", handler);
|
|
54
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
55
|
+
}, [open]);
|
|
56
|
+
|
|
57
|
+
// Focus current on open
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (open) {
|
|
60
|
+
focusedRef.current = Math.max(0, models.findIndex((m) => m.value === value));
|
|
61
|
+
}
|
|
62
|
+
}, [open, value, models]);
|
|
63
|
+
|
|
64
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
65
|
+
if (e.key === "Escape") { setOpen(false); return; }
|
|
66
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
const dir = e.key === "ArrowDown" ? 1 : -1;
|
|
69
|
+
focusedRef.current = (focusedRef.current + dir + models.length) % models.length;
|
|
70
|
+
const el = panelRef.current?.querySelector(`[data-idx="${focusedRef.current}"]`) as HTMLElement;
|
|
71
|
+
el?.focus();
|
|
72
|
+
}
|
|
73
|
+
if (e.key === "Enter") {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
const m = models[focusedRef.current];
|
|
76
|
+
if (m) { onChange(m.value); setOpen(false); }
|
|
77
|
+
}
|
|
78
|
+
}, [onChange, models]);
|
|
79
|
+
|
|
80
|
+
// Hide when ≤1 model
|
|
81
|
+
if (models.length <= 1) return null;
|
|
82
|
+
|
|
83
|
+
const current = models.find((m) => m.value === value);
|
|
84
|
+
const display = current ? shortLabel(current.label) : (value ?? "Model");
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="relative">
|
|
88
|
+
{/* Chip — same style as ProviderSelector */}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
disabled={disabled}
|
|
92
|
+
onClick={(e) => { e.stopPropagation(); if (!disabled) setOpen((v) => !v); }}
|
|
93
|
+
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-text-subtle hover:text-text-primary hover:bg-surface-elevated transition-colors border border-transparent hover:border-border disabled:opacity-50 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent disabled:hover:text-text-subtle"
|
|
94
|
+
aria-label={`Model: ${current?.label ?? value ?? "default"}`}
|
|
95
|
+
title={disabled ? "Model can't change while running" : current?.label ?? undefined}
|
|
96
|
+
>
|
|
97
|
+
<Sparkles className="h-3.5 w-3.5 shrink-0" />
|
|
98
|
+
<span className="max-w-[90px] truncate">{display}</span>
|
|
99
|
+
</button>
|
|
100
|
+
|
|
101
|
+
{/* Popup panel */}
|
|
102
|
+
{open && !disabled && (
|
|
103
|
+
<div
|
|
104
|
+
ref={panelRef}
|
|
105
|
+
role="listbox"
|
|
106
|
+
aria-label="Models"
|
|
107
|
+
onKeyDown={handleKeyDown}
|
|
108
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
109
|
+
onClick={(e) => e.stopPropagation()}
|
|
110
|
+
className="absolute bottom-full left-0 mb-1 z-50 w-56 rounded-lg border border-border bg-surface shadow-lg"
|
|
111
|
+
>
|
|
112
|
+
<div className="px-3 py-2 border-b border-border">
|
|
113
|
+
<span className="text-xs font-medium text-text-secondary">Model</span>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="py-1">
|
|
116
|
+
{models.map((m, idx) => {
|
|
117
|
+
const isActive = m.value === value;
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
key={m.value}
|
|
121
|
+
data-idx={idx}
|
|
122
|
+
role="option"
|
|
123
|
+
aria-selected={isActive}
|
|
124
|
+
tabIndex={0}
|
|
125
|
+
onClick={() => { onChange(m.value); setOpen(false); }}
|
|
126
|
+
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-surface-elevated focus:bg-surface-elevated focus:outline-none ${isActive ? "bg-surface-elevated" : ""}`}
|
|
127
|
+
>
|
|
128
|
+
<span className="flex-1 text-sm font-medium text-text-primary">{m.label}</span>
|
|
129
|
+
{isActive && <Check className="size-4 shrink-0 text-primary" />}
|
|
130
|
+
</button>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -160,6 +160,7 @@ function ProxyTestForm({ authKey, baseUrl }: ProxyTestDialogProps) {
|
|
|
160
160
|
className="h-8 w-full rounded-md border bg-background px-2 text-[11px]"
|
|
161
161
|
>
|
|
162
162
|
<option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
|
|
163
|
+
<option value="claude-opus-4-8">claude-opus-4-8</option>
|
|
163
164
|
<option value="claude-opus-4-7">claude-opus-4-7</option>
|
|
164
165
|
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5</option>
|
|
165
166
|
<option value="claude-opus-4-6">claude-opus-4-6</option>
|
|
@@ -58,6 +58,10 @@ interface UseChatReturn {
|
|
|
58
58
|
compactStatus: "compacting" | null;
|
|
59
59
|
statusMessage: string | null;
|
|
60
60
|
sessionTitle: string | null;
|
|
61
|
+
/** Per-session model override (null = provider default) */
|
|
62
|
+
model: string | null;
|
|
63
|
+
/** Switch the per-session model (persists + recreates query on next message) */
|
|
64
|
+
setModel: (model: string) => void;
|
|
61
65
|
/** Team activity state from WS events */
|
|
62
66
|
teamActivity: TeamActivityState;
|
|
63
67
|
/** All team messages (ref-backed, updated live) */
|
|
@@ -99,6 +103,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
99
103
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
100
104
|
const [sessionTitle, setSessionTitle] = useState<string | null>(null);
|
|
101
105
|
const [isConnected, setIsConnected] = useState(false);
|
|
106
|
+
const [model, setModelState] = useState<string | null>(null);
|
|
107
|
+
const modelRef = useRef<string | null>(null);
|
|
102
108
|
const streamingContentRef = useRef("");
|
|
103
109
|
const streamingEventsRef = useRef<ChatEvent[]>([]);
|
|
104
110
|
const bashOutputRef = useRef<Map<string, BashPartialEntry>>(new Map());
|
|
@@ -540,6 +546,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
540
546
|
setPhase(p);
|
|
541
547
|
phaseRef.current = p;
|
|
542
548
|
if (state.sessionTitle) setSessionTitle(state.sessionTitle);
|
|
549
|
+
if (state.model) { setModelState(state.model); modelRef.current = state.model; }
|
|
543
550
|
if (state.pendingApproval) {
|
|
544
551
|
setPendingApproval({
|
|
545
552
|
requestId: state.pendingApproval.requestId,
|
|
@@ -737,11 +744,21 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
737
744
|
permissionMode: opts?.permissionMode,
|
|
738
745
|
priority: opts?.priority,
|
|
739
746
|
images: opts?.images,
|
|
747
|
+
...(modelRef.current && { model: modelRef.current }),
|
|
740
748
|
}));
|
|
741
749
|
},
|
|
742
750
|
[send],
|
|
743
751
|
);
|
|
744
752
|
|
|
753
|
+
const setModel = useCallback(
|
|
754
|
+
(nextModel: string) => {
|
|
755
|
+
setModelState(nextModel); // optimistic
|
|
756
|
+
modelRef.current = nextModel;
|
|
757
|
+
send(JSON.stringify({ type: "set_model", model: nextModel }));
|
|
758
|
+
},
|
|
759
|
+
[send],
|
|
760
|
+
);
|
|
761
|
+
|
|
745
762
|
const respondToApproval = useCallback(
|
|
746
763
|
(requestId: string, approved: boolean, data?: unknown) => {
|
|
747
764
|
send(
|
|
@@ -879,6 +896,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
879
896
|
compactStatus,
|
|
880
897
|
statusMessage,
|
|
881
898
|
sessionTitle,
|
|
899
|
+
model,
|
|
900
|
+
setModel,
|
|
882
901
|
teamActivity,
|
|
883
902
|
teamMessages,
|
|
884
903
|
markTeamRead,
|