@hienlh/ppm 0.7.41 → 0.8.1
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 +28 -0
- package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
- package/dist/web/assets/{code-editor-CaKnPjkU.js → code-editor-BakDn6rL.js} +1 -1
- package/dist/web/assets/{database-viewer-DUAq3r2M.js → database-viewer-COaZMlpv.js} +1 -1
- package/dist/web/assets/{diff-viewer-C6w7tDMN.js → diff-viewer-COSbmidI.js} +1 -1
- package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
- package/dist/web/assets/index-BGTzm7B1.js +28 -0
- package/dist/web/assets/index-CeNox-VV.css +2 -0
- package/dist/web/assets/input-CE3bFwLk.js +41 -0
- package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
- package/dist/web/assets/{markdown-renderer-Ckj0mfYc.js → markdown-renderer-BKgH2iGf.js} +1 -1
- package/dist/web/assets/{postgres-viewer-m6qNfnAF.js → postgres-viewer-DBOv2ha2.js} +1 -1
- package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
- package/dist/web/assets/{sqlite-viewer-6d233-2k.js → sqlite-viewer-BY242odW.js} +1 -1
- package/dist/web/assets/switch-BEmt1alu.js +1 -0
- package/dist/web/assets/{terminal-tab-BaHGzGJ6.js → terminal-tab-BiUqECPk.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +168 -71
- package/src/providers/mock-provider.ts +1 -0
- package/src/providers/provider.interface.ts +1 -0
- package/src/server/routes/git.ts +16 -2
- package/src/server/ws/chat.ts +43 -26
- package/src/services/account-selector.service.ts +18 -2
- package/src/services/chat.service.ts +3 -1
- package/src/services/git.service.ts +45 -8
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +7 -1
- package/src/types/config.ts +21 -0
- package/src/types/git.ts +4 -0
- package/src/web/components/chat/chat-tab.tsx +26 -8
- package/src/web/components/chat/message-input.tsx +61 -1
- package/src/web/components/chat/message-list.tsx +9 -1
- package/src/web/components/chat/mode-selector.tsx +117 -0
- package/src/web/components/git/git-graph-branch-label.tsx +124 -0
- package/src/web/components/git/git-graph-constants.ts +185 -0
- package/src/web/components/git/git-graph-detail.tsx +107 -0
- package/src/web/components/git/git-graph-dialog.tsx +72 -0
- package/src/web/components/git/git-graph-row.tsx +167 -0
- package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
- package/src/web/components/git/git-graph-svg.tsx +54 -0
- package/src/web/components/git/git-graph-toolbar.tsx +195 -0
- package/src/web/components/git/git-graph.tsx +143 -681
- package/src/web/components/git/use-column-resize.ts +33 -0
- package/src/web/components/git/use-git-graph.ts +201 -0
- package/src/web/components/settings/ai-settings-section.tsx +42 -0
- package/src/web/hooks/use-chat.ts +3 -3
- package/src/web/lib/api-settings.ts +2 -0
- package/dist/web/assets/chat-tab-BoeC0a0w.js +0 -7
- package/dist/web/assets/git-graph-9GFTfA5p.js +0 -1
- package/dist/web/assets/index-CSS8Cy7l.css +0 -2
- package/dist/web/assets/index-CetGEOKq.js +0 -28
- package/dist/web/assets/input-CVIzrYsH.js +0 -41
- package/dist/web/assets/keybindings-store-DiEM7YZ4.js +0 -1
- package/dist/web/assets/settings-tab-Di-E48kC.js +0 -1
- package/dist/web/assets/switch-UODDpwuO.js +0 -1
|
@@ -106,7 +106,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
106
106
|
if (!event || typeof event !== "object") return null;
|
|
107
107
|
const e = event as Record<string, unknown>;
|
|
108
108
|
if (e.type === "result" && e.subtype === "error_during_execution") {
|
|
109
|
-
|
|
109
|
+
// SDK uses `errors: string[]` array for error details
|
|
110
|
+
const errorsArr = Array.isArray(e.errors) ? (e.errors as string[]).join(" ") : "";
|
|
111
|
+
const msg = errorsArr || String(e.error ?? "");
|
|
110
112
|
if (msg.includes("429") || msg.toLowerCase().includes("rate limit") || msg.toLowerCase().includes("overloaded")) return 429;
|
|
111
113
|
if (msg.includes("401") || msg.toLowerCase().includes("unauthorized") || msg.toLowerCase().includes("invalid api key")) return 401;
|
|
112
114
|
}
|
|
@@ -151,7 +153,15 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
151
153
|
if (opts.providerConfig.effort) args.push("--effort", opts.providerConfig.effort);
|
|
152
154
|
|
|
153
155
|
// Permission mode
|
|
154
|
-
args.push("--permission-mode", "bypassPermissions"
|
|
156
|
+
args.push("--permission-mode", opts.providerConfig.permission_mode ?? "bypassPermissions");
|
|
157
|
+
if ((opts.providerConfig.permission_mode ?? "bypassPermissions") === "bypassPermissions") {
|
|
158
|
+
args.push("--dangerously-skip-permissions");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// System prompt — CLI uses --append-system-prompt flag
|
|
162
|
+
if (opts.providerConfig.system_prompt) {
|
|
163
|
+
args.push("--append-system-prompt", opts.providerConfig.system_prompt);
|
|
164
|
+
}
|
|
155
165
|
|
|
156
166
|
// On Windows, `claude` is a .cmd wrapper (npm global) — Bun.spawn can't resolve .cmd
|
|
157
167
|
// files directly. Use `cmd /c` to let the Windows shell find it via PATH.
|
|
@@ -391,7 +401,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
391
401
|
async *sendMessage(
|
|
392
402
|
sessionId: string,
|
|
393
403
|
message: string,
|
|
394
|
-
opts?: { forkSession?: boolean },
|
|
404
|
+
opts?: import("./provider.interface.ts").SendMessageOpts & { forkSession?: boolean },
|
|
395
405
|
): AsyncIterable<ChatEvent> {
|
|
396
406
|
if (!this.activeSessions.has(sessionId)) {
|
|
397
407
|
await this.resumeSession(sessionId);
|
|
@@ -411,58 +421,104 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
411
421
|
const shouldFork = !!forkSourceId && isFirstMessage;
|
|
412
422
|
if (forkSourceId) this.forkSources.delete(sessionId);
|
|
413
423
|
|
|
424
|
+
// Resolve permission mode early — canUseTool needs isBypass
|
|
425
|
+
const providerConfig = this.getProviderConfig();
|
|
426
|
+
const permissionMode = opts?.permissionMode || providerConfig.permission_mode || "bypassPermissions";
|
|
427
|
+
const isBypass = permissionMode === "bypassPermissions";
|
|
428
|
+
const systemPromptOpt = providerConfig.system_prompt
|
|
429
|
+
? { type: "custom" as const, value: providerConfig.system_prompt }
|
|
430
|
+
: { type: "preset" as const, preset: "claude_code" as const };
|
|
431
|
+
|
|
432
|
+
// Build allowedTools based on permission mode.
|
|
433
|
+
// SDK auto-approves everything in allowedTools (skips canUseTool callback).
|
|
434
|
+
// In non-bypass modes, only pre-approve read-only tools so write/execute tools
|
|
435
|
+
// go through the permission evaluation chain → canUseTool callback.
|
|
436
|
+
const readOnlyTools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch", "ToolSearch"];
|
|
437
|
+
const writeTools = ["Write", "Edit", "Bash", "Agent", "Skill", "TodoWrite", "AskUserQuestion"];
|
|
438
|
+
const allowedTools = isBypass
|
|
439
|
+
? [...readOnlyTools, ...writeTools]
|
|
440
|
+
: readOnlyTools;
|
|
441
|
+
|
|
414
442
|
/**
|
|
415
443
|
* Approval events to yield from the generator.
|
|
416
|
-
*
|
|
444
|
+
* PreToolUse hook pushes events here; the main loop yields them.
|
|
417
445
|
*/
|
|
418
446
|
const approvalEvents: ChatEvent[] = [];
|
|
419
447
|
let approvalNotify: (() => void) | undefined;
|
|
420
448
|
|
|
421
449
|
/**
|
|
422
|
-
*
|
|
423
|
-
|
|
450
|
+
* Helper: send approval request to FE and wait for response.
|
|
451
|
+
*/
|
|
452
|
+
const waitForApproval = (toolName: string, input: unknown): Promise<{ approved: boolean; data?: unknown }> => {
|
|
453
|
+
const requestId = crypto.randomUUID();
|
|
454
|
+
const APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
455
|
+
const promise = new Promise<{ approved: boolean; data?: unknown }>((resolve) => {
|
|
456
|
+
this.pendingApprovals.set(requestId, { resolve });
|
|
457
|
+
setTimeout(() => {
|
|
458
|
+
if (this.pendingApprovals.has(requestId)) {
|
|
459
|
+
this.pendingApprovals.delete(requestId);
|
|
460
|
+
resolve({ approved: false });
|
|
461
|
+
}
|
|
462
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
463
|
+
});
|
|
464
|
+
approvalEvents.push({ type: "approval_request", requestId, tool: toolName, input });
|
|
465
|
+
approvalNotify?.();
|
|
466
|
+
return promise;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* canUseTool: handles AskUserQuestion (always surfaces to FE regardless of mode).
|
|
471
|
+
* Tool permission for Write/Edit/Bash is handled by the PreToolUse hook below.
|
|
424
472
|
*/
|
|
425
473
|
const canUseTool = async (toolName: string, input: unknown) => {
|
|
426
|
-
|
|
427
|
-
if (toolName
|
|
428
|
-
|
|
474
|
+
console.log(`[sdk] canUseTool called: tool=${toolName} permissionMode=${permissionMode}`);
|
|
475
|
+
if (toolName === "AskUserQuestion") {
|
|
476
|
+
const result = await waitForApproval(toolName, input);
|
|
477
|
+
if (result.approved && result.data) {
|
|
478
|
+
return {
|
|
479
|
+
behavior: "allow" as const,
|
|
480
|
+
updatedInput: { ...(input as Record<string, unknown>), answers: result.data },
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return { behavior: "deny" as const, message: "User skipped the question" };
|
|
429
484
|
}
|
|
485
|
+
return { behavior: "allow" as const, updatedInput: input };
|
|
486
|
+
};
|
|
430
487
|
|
|
431
|
-
|
|
488
|
+
/**
|
|
489
|
+
* PreToolUse hook: runs FIRST in SDK evaluation order (Hooks → Deny → PermMode → Allow → canUseTool).
|
|
490
|
+
* User settings hooks (scout-block, etc.) return exit 0 → SDK treats as "allow", preventing canUseTool.
|
|
491
|
+
* This in-process hook handles permission mode decisions before external hooks auto-approve.
|
|
492
|
+
*/
|
|
493
|
+
const preToolUseHook = async (hookInput: any) => {
|
|
494
|
+
const toolName = hookInput?.tool_name as string | undefined;
|
|
495
|
+
if (!toolName) return {};
|
|
496
|
+
console.log(`[sdk] preToolUseHook: tool=${toolName} permissionMode=${permissionMode} isBypass=${isBypass}`);
|
|
432
497
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
(resolve) => {
|
|
436
|
-
this.pendingApprovals.set(requestId, { resolve });
|
|
437
|
-
// Auto-deny after timeout if FE doesn't respond
|
|
438
|
-
setTimeout(() => {
|
|
439
|
-
if (this.pendingApprovals.has(requestId)) {
|
|
440
|
-
this.pendingApprovals.delete(requestId);
|
|
441
|
-
resolve({ approved: false });
|
|
442
|
-
}
|
|
443
|
-
}, APPROVAL_TIMEOUT_MS);
|
|
444
|
-
},
|
|
445
|
-
);
|
|
498
|
+
// Bypass mode: allow everything
|
|
499
|
+
if (isBypass) return {};
|
|
446
500
|
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
type: "approval_request",
|
|
450
|
-
requestId,
|
|
451
|
-
tool: toolName,
|
|
452
|
-
input,
|
|
453
|
-
});
|
|
454
|
-
approvalNotify?.();
|
|
501
|
+
// Read-only tools: always allow
|
|
502
|
+
if (readOnlyTools.includes(toolName)) return {};
|
|
455
503
|
|
|
456
|
-
//
|
|
457
|
-
|
|
504
|
+
// AskUserQuestion: handled by canUseTool callback
|
|
505
|
+
if (toolName === "AskUserQuestion") return {};
|
|
458
506
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
};
|
|
507
|
+
// Non-bypass mode: ask FE for approval on write/execute tools
|
|
508
|
+
const result = await waitForApproval(toolName, hookInput?.tool_input);
|
|
509
|
+
if (result.approved) {
|
|
510
|
+
return { hookSpecificOutput: { permissionDecision: "allow" } };
|
|
464
511
|
}
|
|
465
|
-
return {
|
|
512
|
+
return { hookSpecificOutput: { permissionDecision: "deny", message: "User denied tool execution" } };
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Hooks config: add our permission hook for non-bypass modes
|
|
516
|
+
const permissionHooks = isBypass ? undefined : {
|
|
517
|
+
PreToolUse: [{
|
|
518
|
+
matcher: ".*", // Match all tools — our hook checks internally
|
|
519
|
+
hooks: [preToolUseHook],
|
|
520
|
+
timeout: 300, // 5min for user approval
|
|
521
|
+
}],
|
|
466
522
|
};
|
|
467
523
|
|
|
468
524
|
let assistantContent = "";
|
|
@@ -470,7 +526,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
470
526
|
let resultNumTurns: number | undefined;
|
|
471
527
|
let resultContextWindowPct: number | undefined;
|
|
472
528
|
try {
|
|
473
|
-
const providerConfig = this.getProviderConfig();
|
|
474
529
|
// Resolve SDK's actual session ID for resume (may differ from PPM's UUID)
|
|
475
530
|
// For fork: use the source session's SDK id
|
|
476
531
|
const sdkId = shouldFork ? getSdkSessionId(forkSourceId!) : getSdkSessionId(sessionId);
|
|
@@ -480,13 +535,25 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
480
535
|
|
|
481
536
|
// Account-based auth injection (multi-account mode)
|
|
482
537
|
// Fallback to existing env (ANTHROPIC_API_KEY) when no accounts configured.
|
|
483
|
-
const
|
|
538
|
+
const accountsEnabled = accountSelector.isEnabled();
|
|
539
|
+
const account = accountsEnabled ? accountSelector.next() : null;
|
|
540
|
+
if (accountsEnabled && !account) {
|
|
541
|
+
// All accounts in DB but none usable
|
|
542
|
+
const reason = accountSelector.lastFailReason;
|
|
543
|
+
const hint = reason === "all_decrypt_failed"
|
|
544
|
+
? "Account tokens were encrypted with a different machine key. Re-add your accounts in Settings, or copy ~/.ppm/account.key from the original machine."
|
|
545
|
+
: "All accounts are disabled or in cooldown. Check Settings → Accounts.";
|
|
546
|
+
console.error(`[sdk] session=${sessionId} account auth failed (${reason}): ${hint}`);
|
|
547
|
+
yield { type: "error" as const, message: `Authentication failed: ${hint}` };
|
|
548
|
+
yield { type: "done" as const, sessionId, resultSubtype: "error_auth" };
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
484
551
|
if (account) {
|
|
485
552
|
console.log(`[sdk] Using account ${account.id} (${account.email ?? "no-email"})`);
|
|
486
553
|
yield { type: "account_info" as const, accountId: account.id, accountLabel: account.label ?? account.email ?? "Unknown" };
|
|
487
554
|
}
|
|
488
555
|
const queryEnv = this.buildQueryEnv(meta.projectPath, account);
|
|
489
|
-
console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform} accountMode=${!!account}`);
|
|
556
|
+
console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform} accountMode=${!!account} permissionMode=${permissionMode} isBypass=${isBypass}`);
|
|
490
557
|
|
|
491
558
|
// TODO: Remove when TS SDK fixes Windows stdin pipe buffering (see queryDirectCli() JSDoc for tracking issues)
|
|
492
559
|
// On Windows, SDK query() hangs because Bun subprocess stdin pipe never flushes to child process.
|
|
@@ -528,17 +595,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
528
595
|
resume: (isFirstMessage && !shouldFork) ? undefined : sdkId,
|
|
529
596
|
...(shouldFork && { forkSession: true }),
|
|
530
597
|
cwd: effectiveCwd,
|
|
531
|
-
systemPrompt:
|
|
598
|
+
systemPrompt: systemPromptOpt,
|
|
532
599
|
settingSources: ["user", "project"],
|
|
533
600
|
env: queryEnv,
|
|
534
601
|
settings: { permissions: { allow: [], deny: [] } },
|
|
535
|
-
allowedTools
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
],
|
|
540
|
-
permissionMode: "bypassPermissions",
|
|
541
|
-
allowDangerouslySkipPermissions: true,
|
|
602
|
+
allowedTools,
|
|
603
|
+
permissionMode,
|
|
604
|
+
allowDangerouslySkipPermissions: isBypass,
|
|
605
|
+
...(permissionHooks && { hooks: permissionHooks }),
|
|
542
606
|
...(providerConfig.model && { model: providerConfig.model }),
|
|
543
607
|
...(providerConfig.effort && { effort: providerConfig.effort }),
|
|
544
608
|
maxTurns: providerConfig.max_turns ?? 100,
|
|
@@ -583,17 +647,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
583
647
|
resume: undefined,
|
|
584
648
|
...(shouldFork && { forkSession: true }),
|
|
585
649
|
cwd: effectiveCwd,
|
|
586
|
-
systemPrompt:
|
|
650
|
+
systemPrompt: systemPromptOpt,
|
|
587
651
|
settingSources: ["user", "project"],
|
|
588
652
|
env: queryEnv,
|
|
589
653
|
settings: { permissions: { allow: [], deny: [] } },
|
|
590
|
-
allowedTools
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
],
|
|
595
|
-
permissionMode: "bypassPermissions",
|
|
596
|
-
allowDangerouslySkipPermissions: true,
|
|
654
|
+
allowedTools,
|
|
655
|
+
permissionMode,
|
|
656
|
+
allowDangerouslySkipPermissions: isBypass,
|
|
657
|
+
...(permissionHooks && { hooks: permissionHooks }),
|
|
597
658
|
...(providerConfig.model && { model: providerConfig.model }),
|
|
598
659
|
...(providerConfig.effort && { effort: providerConfig.effort }),
|
|
599
660
|
maxTurns: providerConfig.max_turns ?? 100,
|
|
@@ -727,6 +788,20 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
727
788
|
|
|
728
789
|
// Full assistant message
|
|
729
790
|
if (msg.type === "assistant") {
|
|
791
|
+
// SDK assistant messages can carry an error field for auth/billing/rate-limit failures
|
|
792
|
+
const assistantError = (msg as any).error as string | undefined;
|
|
793
|
+
if (assistantError) {
|
|
794
|
+
const errorHints: Record<string, string> = {
|
|
795
|
+
authentication_failed: "API authentication failed. Check your account credentials in Settings → Accounts.",
|
|
796
|
+
billing_error: "Billing error on this account. Check your subscription status.",
|
|
797
|
+
rate_limit: "Rate limited by the API. Please wait and try again.",
|
|
798
|
+
invalid_request: "Invalid request sent to the API.",
|
|
799
|
+
server_error: "Anthropic API server error. Try again shortly.",
|
|
800
|
+
};
|
|
801
|
+
const hint = errorHints[assistantError] ?? `API error: ${assistantError}`;
|
|
802
|
+
console.error(`[sdk] session=${sessionId} assistant error: ${assistantError}`);
|
|
803
|
+
yield { type: "error", message: hint };
|
|
804
|
+
}
|
|
730
805
|
const content = (msg as any).message?.content;
|
|
731
806
|
if (Array.isArray(content)) {
|
|
732
807
|
for (const block of content) {
|
|
@@ -828,9 +903,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
828
903
|
|
|
829
904
|
// Surface non-success subtypes as errors so FE can display them
|
|
830
905
|
if (subtype && subtype !== "success") {
|
|
831
|
-
//
|
|
832
|
-
const
|
|
833
|
-
const sdkDetail =
|
|
906
|
+
// SDK error results use `errors: string[]` array (not singular `error`)
|
|
907
|
+
const errorsArr = Array.isArray(result.errors) ? result.errors : [];
|
|
908
|
+
const sdkDetail = errorsArr.length > 0
|
|
909
|
+
? errorsArr.join("\n")
|
|
910
|
+
: (typeof result.error === "string" ? result.error : "");
|
|
834
911
|
// Log full result for debugging (truncated at 2000 chars)
|
|
835
912
|
console.error(`[sdk] result error: subtype=${subtype} turns=${result.num_turns ?? 0} detail=${sdkDetail || "(none)"}`);
|
|
836
913
|
console.error(`[sdk] result full dump: ${JSON.stringify(result).slice(0, 2000)}`);
|
|
@@ -840,13 +917,35 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
840
917
|
error_during_execution: "Agent encountered an error during execution.",
|
|
841
918
|
};
|
|
842
919
|
const baseMsg = errorMessages[subtype] ?? `Agent stopped: ${subtype}`;
|
|
843
|
-
|
|
920
|
+
// Add specific hints for common network/auth errors
|
|
921
|
+
const detailLower = sdkDetail.toLowerCase();
|
|
922
|
+
let hint = "";
|
|
923
|
+
if (detailLower.includes("connectionrefused") || detailLower.includes("connection refused") || detailLower.includes("econnrefused")) {
|
|
924
|
+
hint = "\n\nHint: Cannot reach Anthropic API. If running in WSL, check DNS/proxy settings (e.g. `curl -s https://api.anthropic.com` from WSL terminal).";
|
|
925
|
+
} else if (detailLower.includes("unable to connect")) {
|
|
926
|
+
hint = "\n\nHint: Network connectivity issue. Check your internet connection and firewall/proxy settings.";
|
|
927
|
+
} else if (detailLower.includes("401") || detailLower.includes("unauthorized") || detailLower.includes("invalid api key")) {
|
|
928
|
+
hint = "\n\nHint: Authentication failed. Try re-adding your account in Settings → Accounts.";
|
|
929
|
+
}
|
|
930
|
+
const fullMsg = sdkDetail ? `${baseMsg}\n${sdkDetail}${hint}` : baseMsg;
|
|
844
931
|
yield {
|
|
845
932
|
type: "error",
|
|
846
933
|
message: fullMsg,
|
|
847
934
|
};
|
|
848
935
|
}
|
|
849
936
|
|
|
937
|
+
// Detect empty/suspicious success — SDK returned "success" but no real assistant content
|
|
938
|
+
if ((!subtype || subtype === "success") && (result.num_turns ?? 0) === 0 && !assistantContent) {
|
|
939
|
+
// SDK success result has `result: string` containing final text
|
|
940
|
+
const resultText = typeof result.result === "string" ? result.result : "";
|
|
941
|
+
console.warn(`[sdk] session=${sessionId} result success but 0 turns, no assistant content, result="${resultText.slice(0, 200)}"`);
|
|
942
|
+
console.warn(`[sdk] result dump: ${JSON.stringify(result).slice(0, 2000)}`);
|
|
943
|
+
const hint = resultText
|
|
944
|
+
? `Claude returned: "${resultText}"\nThis may indicate a session or connection issue. Try creating a new chat session.`
|
|
945
|
+
: "Claude returned no response (0 turns). This usually means the API connection failed silently. Check that `claude` CLI works in your terminal, or try creating a new chat session.";
|
|
946
|
+
yield { type: "error", message: hint };
|
|
947
|
+
}
|
|
948
|
+
|
|
850
949
|
// Store subtype and numTurns for the done event
|
|
851
950
|
resultSubtype = subtype;
|
|
852
951
|
resultNumTurns = result.num_turns as number | undefined;
|
|
@@ -894,17 +993,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
894
993
|
prompt: message,
|
|
895
994
|
options: {
|
|
896
995
|
cwd: effectiveCwd,
|
|
897
|
-
systemPrompt:
|
|
996
|
+
systemPrompt: systemPromptOpt,
|
|
898
997
|
settingSources: ["user", "project"],
|
|
899
998
|
env: queryEnv,
|
|
900
999
|
settings: { permissions: { allow: [], deny: [] } },
|
|
901
|
-
allowedTools
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
],
|
|
906
|
-
permissionMode: "bypassPermissions",
|
|
907
|
-
allowDangerouslySkipPermissions: true,
|
|
1000
|
+
allowedTools,
|
|
1001
|
+
permissionMode,
|
|
1002
|
+
allowDangerouslySkipPermissions: isBypass,
|
|
1003
|
+
...(permissionHooks && { hooks: permissionHooks }),
|
|
908
1004
|
...(providerConfig.model && { model: providerConfig.model }),
|
|
909
1005
|
maxTurns: providerConfig.max_turns ?? 100,
|
|
910
1006
|
canUseTool,
|
|
@@ -917,7 +1013,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
917
1013
|
if (retryMsg.type === "result") {
|
|
918
1014
|
const r = retryMsg as any;
|
|
919
1015
|
if (r.subtype && r.subtype !== "success") {
|
|
920
|
-
|
|
1016
|
+
const retryErrors = Array.isArray(r.errors) ? r.errors.join("\n") : "";
|
|
1017
|
+
yield { type: "error", message: retryErrors || `Agent stopped: ${r.subtype}` };
|
|
921
1018
|
}
|
|
922
1019
|
resultSubtype = r.subtype;
|
|
923
1020
|
resultNumTurns = r.num_turns;
|
|
@@ -66,6 +66,7 @@ export class MockProvider implements AIProvider {
|
|
|
66
66
|
async *sendMessage(
|
|
67
67
|
sessionId: string,
|
|
68
68
|
message: string,
|
|
69
|
+
_opts?: import("./provider.interface.ts").SendMessageOpts,
|
|
69
70
|
): AsyncIterable<ChatEvent> {
|
|
70
71
|
const session = this.sessions.get(sessionId);
|
|
71
72
|
if (!session) {
|
package/src/server/routes/git.ts
CHANGED
|
@@ -57,12 +57,13 @@ gitRoutes.get("/file-diff", async (c) => {
|
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
/** GET /git/graph?max=200 */
|
|
60
|
+
/** GET /git/graph?max=200&skip=0 */
|
|
61
61
|
gitRoutes.get("/graph", async (c) => {
|
|
62
62
|
try {
|
|
63
63
|
const projectPath = c.get("projectPath");
|
|
64
64
|
const max = parseInt(c.req.query("max") ?? "200", 10);
|
|
65
|
-
const
|
|
65
|
+
const skip = parseInt(c.req.query("skip") ?? "0", 10);
|
|
66
|
+
const data = await gitService.graphData(projectPath, max, skip);
|
|
66
67
|
return c.json(ok(data));
|
|
67
68
|
} catch (e) {
|
|
68
69
|
return c.json(err((e as Error).message), 500);
|
|
@@ -93,6 +94,19 @@ gitRoutes.get("/pr-url", async (c) => {
|
|
|
93
94
|
}
|
|
94
95
|
});
|
|
95
96
|
|
|
97
|
+
/** POST /git/fetch { remote? } */
|
|
98
|
+
gitRoutes.post("/fetch", async (c) => {
|
|
99
|
+
try {
|
|
100
|
+
const projectPath = c.get("projectPath");
|
|
101
|
+
const body = await c.req.json<{ remote?: string }>().catch(() => ({ remote: undefined }));
|
|
102
|
+
const { remote } = body;
|
|
103
|
+
await gitService.fetch(projectPath, remote);
|
|
104
|
+
return c.json(ok({ fetched: true }));
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return c.json(err((e as Error).message), 500);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
96
110
|
/** POST /git/discard { files } — discard unstaged changes (checkout tracked, clean untracked) */
|
|
97
111
|
gitRoutes.post("/discard", async (c) => {
|
|
98
112
|
try {
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -30,6 +30,8 @@ interface SessionEntry {
|
|
|
30
30
|
catchUpText: string;
|
|
31
31
|
/** Reference to the running stream promise — prevents GC */
|
|
32
32
|
streamPromise?: Promise<void>;
|
|
33
|
+
/** Sticky permission mode for this session */
|
|
34
|
+
permissionMode?: string;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** Tracks active sessions — persists even when FE disconnects */
|
|
@@ -78,7 +80,7 @@ function startCleanupTimer(sessionId: string): void {
|
|
|
78
80
|
* Standalone streaming loop — decoupled from WS message handler.
|
|
79
81
|
* Runs independently so WS close does NOT kill the Claude query.
|
|
80
82
|
*/
|
|
81
|
-
async function runStreamLoop(sessionId: string, providerId: string, content: string): Promise<void> {
|
|
83
|
+
async function runStreamLoop(sessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
|
|
82
84
|
const entry = activeSessions.get(sessionId);
|
|
83
85
|
if (!entry) {
|
|
84
86
|
console.error(`[chat] session=${sessionId} runStreamLoop: no entry — aborting`);
|
|
@@ -138,7 +140,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
138
140
|
safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed });
|
|
139
141
|
}, 5_000);
|
|
140
142
|
|
|
141
|
-
for await (const event of chatService.sendMessage(providerId, sessionId, content)) {
|
|
143
|
+
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
|
|
142
144
|
if (abortController.signal.aborted) break;
|
|
143
145
|
eventCount++;
|
|
144
146
|
const ev = event as any;
|
|
@@ -369,27 +371,47 @@ export const chatWebSocket = {
|
|
|
369
371
|
entry0.ws = ws;
|
|
370
372
|
}
|
|
371
373
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
+
let entry = activeSessions.get(sessionId);
|
|
375
|
+
|
|
376
|
+
// Auto-create entry if missing — handles: message before open (Bun race), or session cleaned up
|
|
377
|
+
if (!entry) {
|
|
378
|
+
const { projectName: pn } = ws.data;
|
|
379
|
+
const session = chatService.getSession(sessionId);
|
|
380
|
+
const pid = session?.providerId ?? providerRegistry.getDefault().id;
|
|
381
|
+
let pp: string | undefined;
|
|
382
|
+
if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
|
|
383
|
+
const pi = setInterval(() => {
|
|
384
|
+
try { ws.send(JSON.stringify({ type: "ping" })); } catch { /* ws may be closed */ }
|
|
385
|
+
}, PING_INTERVAL_MS);
|
|
386
|
+
activeSessions.set(sessionId, {
|
|
387
|
+
providerId: pid, ws, projectPath: pp, projectName: pn,
|
|
388
|
+
pingInterval: pi, isStreaming: false, needsCatchUp: false, catchUpText: "",
|
|
389
|
+
});
|
|
390
|
+
entry = activeSessions.get(sessionId)!;
|
|
391
|
+
console.log(`[chat] session=${sessionId} auto-created entry in message handler`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const providerId = entry.providerId ?? providerRegistry.getDefault().id;
|
|
374
395
|
|
|
375
396
|
// Client-initiated handshake — FE sends "ready" after onopen.
|
|
376
397
|
// Re-send status so tunnel connections (Cloudflare) that missed the
|
|
377
398
|
// open-handler message still get connected/status confirmation.
|
|
378
399
|
if (parsed.type === "ready") {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}));
|
|
386
|
-
} else {
|
|
387
|
-
ws.send(JSON.stringify({ type: "connected", sessionId }));
|
|
388
|
-
}
|
|
400
|
+
ws.send(JSON.stringify({
|
|
401
|
+
type: "status",
|
|
402
|
+
sessionId,
|
|
403
|
+
isStreaming: entry.isStreaming,
|
|
404
|
+
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
405
|
+
}));
|
|
389
406
|
return;
|
|
390
407
|
}
|
|
391
408
|
|
|
392
409
|
if (parsed.type === "message") {
|
|
410
|
+
// Store permission mode — sticky for this session
|
|
411
|
+
if (parsed.permissionMode) {
|
|
412
|
+
entry.permissionMode = parsed.permissionMode;
|
|
413
|
+
}
|
|
414
|
+
|
|
393
415
|
// Send immediate feedback BEFORE any async work — prevents "stuck thinking"
|
|
394
416
|
// when resumeSession is slow (e.g. sdkListSessions spawns subprocess on first call)
|
|
395
417
|
safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed: 0 });
|
|
@@ -405,12 +427,12 @@ export const chatWebSocket = {
|
|
|
405
427
|
logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
|
|
406
428
|
}
|
|
407
429
|
}
|
|
408
|
-
if (entry
|
|
430
|
+
if (entry.projectPath && provider && "ensureProjectPath" in provider) {
|
|
409
431
|
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
410
432
|
}
|
|
411
433
|
|
|
412
434
|
// If already streaming, abort current query first and wait for cleanup
|
|
413
|
-
if (entry
|
|
435
|
+
if (entry.isStreaming && entry.abort) {
|
|
414
436
|
console.log(`[chat] session=${sessionId} aborting current query for new message`);
|
|
415
437
|
entry.abort.abort();
|
|
416
438
|
// Wait for stream loop to finish cleanup
|
|
@@ -421,16 +443,11 @@ export const chatWebSocket = {
|
|
|
421
443
|
|
|
422
444
|
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
423
445
|
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
});
|
|
430
|
-
} else {
|
|
431
|
-
console.warn(`[chat] session=${sessionId} no entry when starting stream loop — message may have arrived before open()`);
|
|
432
|
-
setTimeout(() => runStreamLoop(sessionId, providerId, parsed.content), 0);
|
|
433
|
-
}
|
|
446
|
+
entry.streamPromise = new Promise<void>((resolve) => {
|
|
447
|
+
setTimeout(() => {
|
|
448
|
+
runStreamLoop(sessionId, providerId, parsed.content, entry.permissionMode).then(resolve, resolve);
|
|
449
|
+
}, 0);
|
|
450
|
+
});
|
|
434
451
|
} else if (parsed.type === "cancel") {
|
|
435
452
|
const provider = providerRegistry.get(providerId);
|
|
436
453
|
if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
|
|
@@ -36,11 +36,20 @@ class AccountSelectorService {
|
|
|
36
36
|
setConfigValue(MAX_RETRY_CONFIG_KEY, String(n));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Reason for the last null return from next() */
|
|
40
|
+
private _lastFailReason: "none" | "no_active" | "all_decrypt_failed" = "none";
|
|
41
|
+
|
|
42
|
+
/** Why the last next() call returned null */
|
|
43
|
+
get lastFailReason(): "none" | "no_active" | "all_decrypt_failed" {
|
|
44
|
+
return this._lastFailReason;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
/**
|
|
40
48
|
* Pick next available account (skips cooldown/disabled).
|
|
41
49
|
* Returns null if no active accounts available.
|
|
42
50
|
*/
|
|
43
51
|
next(): AccountWithTokens | null {
|
|
52
|
+
this._lastFailReason = "none";
|
|
44
53
|
const now = Math.floor(Date.now() / 1000);
|
|
45
54
|
const allAccounts = accountService.list();
|
|
46
55
|
|
|
@@ -53,7 +62,10 @@ class AccountSelectorService {
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
const active = accountService.list().filter((a) => a.status === "active");
|
|
56
|
-
if (active.length === 0)
|
|
65
|
+
if (active.length === 0) {
|
|
66
|
+
this._lastFailReason = "no_active";
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
57
69
|
|
|
58
70
|
let pickedId: string;
|
|
59
71
|
if (this.getStrategy() === "fill-first") {
|
|
@@ -66,7 +78,11 @@ class AccountSelectorService {
|
|
|
66
78
|
this.cursor = (this.cursor + 1) % active.length;
|
|
67
79
|
}
|
|
68
80
|
this._lastPickedId = pickedId;
|
|
69
|
-
|
|
81
|
+
const result = accountService.getWithTokens(pickedId);
|
|
82
|
+
if (!result) {
|
|
83
|
+
this._lastFailReason = "all_decrypt_failed";
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
/** Called when account receives 429 — apply exponential backoff */
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
SessionInfo,
|
|
6
6
|
ChatEvent,
|
|
7
7
|
ChatMessage,
|
|
8
|
+
SendMessageOpts,
|
|
8
9
|
} from "../providers/provider.interface.ts";
|
|
9
10
|
import { MockProvider } from "../providers/mock-provider.ts";
|
|
10
11
|
|
|
@@ -70,13 +71,14 @@ class ChatService {
|
|
|
70
71
|
providerId: string,
|
|
71
72
|
sessionId: string,
|
|
72
73
|
message: string,
|
|
74
|
+
opts?: SendMessageOpts,
|
|
73
75
|
): AsyncIterable<ChatEvent> {
|
|
74
76
|
const provider = providerRegistry.get(providerId);
|
|
75
77
|
if (!provider) {
|
|
76
78
|
yield { type: "error", message: `Provider "${providerId}" not found` };
|
|
77
79
|
return;
|
|
78
80
|
}
|
|
79
|
-
yield* provider.sendMessage(sessionId, message);
|
|
81
|
+
yield* provider.sendMessage(sessionId, message, opts);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/** Look up a session across all providers (for WS handler) */
|