@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/web/assets/chat-tab-LTwYS5_e.js +7 -0
  3. package/dist/web/assets/{code-editor-CaKnPjkU.js → code-editor-BakDn6rL.js} +1 -1
  4. package/dist/web/assets/{database-viewer-DUAq3r2M.js → database-viewer-COaZMlpv.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-C6w7tDMN.js → diff-viewer-COSbmidI.js} +1 -1
  6. package/dist/web/assets/git-graph-CKoW0Ky-.js +1 -0
  7. package/dist/web/assets/index-BGTzm7B1.js +28 -0
  8. package/dist/web/assets/index-CeNox-VV.css +2 -0
  9. package/dist/web/assets/input-CE3bFwLk.js +41 -0
  10. package/dist/web/assets/keybindings-store-FQhxQ72s.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-Ckj0mfYc.js → markdown-renderer-BKgH2iGf.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-m6qNfnAF.js → postgres-viewer-DBOv2ha2.js} +1 -1
  13. package/dist/web/assets/settings-tab-BZqkWI4u.js +1 -0
  14. package/dist/web/assets/{sqlite-viewer-6d233-2k.js → sqlite-viewer-BY242odW.js} +1 -1
  15. package/dist/web/assets/switch-BEmt1alu.js +1 -0
  16. package/dist/web/assets/{terminal-tab-BaHGzGJ6.js → terminal-tab-BiUqECPk.js} +1 -1
  17. package/dist/web/index.html +4 -4
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/providers/claude-agent-sdk.ts +168 -71
  21. package/src/providers/mock-provider.ts +1 -0
  22. package/src/providers/provider.interface.ts +1 -0
  23. package/src/server/routes/git.ts +16 -2
  24. package/src/server/ws/chat.ts +43 -26
  25. package/src/services/account-selector.service.ts +18 -2
  26. package/src/services/chat.service.ts +3 -1
  27. package/src/services/git.service.ts +45 -8
  28. package/src/types/api.ts +1 -1
  29. package/src/types/chat.ts +7 -1
  30. package/src/types/config.ts +21 -0
  31. package/src/types/git.ts +4 -0
  32. package/src/web/components/chat/chat-tab.tsx +26 -8
  33. package/src/web/components/chat/message-input.tsx +61 -1
  34. package/src/web/components/chat/message-list.tsx +9 -1
  35. package/src/web/components/chat/mode-selector.tsx +117 -0
  36. package/src/web/components/git/git-graph-branch-label.tsx +124 -0
  37. package/src/web/components/git/git-graph-constants.ts +185 -0
  38. package/src/web/components/git/git-graph-detail.tsx +107 -0
  39. package/src/web/components/git/git-graph-dialog.tsx +72 -0
  40. package/src/web/components/git/git-graph-row.tsx +167 -0
  41. package/src/web/components/git/git-graph-settings-dialog.tsx +104 -0
  42. package/src/web/components/git/git-graph-svg.tsx +54 -0
  43. package/src/web/components/git/git-graph-toolbar.tsx +195 -0
  44. package/src/web/components/git/git-graph.tsx +143 -681
  45. package/src/web/components/git/use-column-resize.ts +33 -0
  46. package/src/web/components/git/use-git-graph.ts +201 -0
  47. package/src/web/components/settings/ai-settings-section.tsx +42 -0
  48. package/src/web/hooks/use-chat.ts +3 -3
  49. package/src/web/lib/api-settings.ts +2 -0
  50. package/dist/web/assets/chat-tab-BoeC0a0w.js +0 -7
  51. package/dist/web/assets/git-graph-9GFTfA5p.js +0 -1
  52. package/dist/web/assets/index-CSS8Cy7l.css +0 -2
  53. package/dist/web/assets/index-CetGEOKq.js +0 -28
  54. package/dist/web/assets/input-CVIzrYsH.js +0 -41
  55. package/dist/web/assets/keybindings-store-DiEM7YZ4.js +0 -1
  56. package/dist/web/assets/settings-tab-Di-E48kC.js +0 -1
  57. 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
- const msg = String(e.error ?? e.error_message ?? e.message ?? "");
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", "--dangerously-skip-permissions");
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
- * canUseTool pushes events here; the main loop yields them.
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
- * canUseTool: only fires for AskUserQuestion (bypassPermissions auto-approves other tools).
423
- * Pauses SDK execution, yields approval_request to FE, waits for user response.
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
- // Non-AskUserQuestion tools: auto-approve (shouldn't reach here with bypassPermissions)
427
- if (toolName !== "AskUserQuestion") {
428
- return { behavior: "allow" as const, updatedInput: input };
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
- const requestId = crypto.randomUUID();
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
- const APPROVAL_TIMEOUT_MS = 5 * 60_000; // 5min extended for FE reconnect
434
- const approvalPromise = new Promise<{ approved: boolean; data?: unknown }>(
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
- // Queue event for the generator to yield to FE
448
- approvalEvents.push({
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
- // Wait for FE to send back answers (or timeout)
457
- const result = await approvalPromise;
504
+ // AskUserQuestion: handled by canUseTool callback
505
+ if (toolName === "AskUserQuestion") return {};
458
506
 
459
- if (result.approved && result.data) {
460
- return {
461
- behavior: "allow" as const,
462
- updatedInput: { ...(input as Record<string, unknown>), answers: result.data },
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 { behavior: "deny" as const, message: "User skipped the question" };
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 account = accountSelector.isEnabled() ? accountSelector.next() : null;
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: { type: "preset", preset: "claude_code" },
598
+ systemPrompt: systemPromptOpt,
532
599
  settingSources: ["user", "project"],
533
600
  env: queryEnv,
534
601
  settings: { permissions: { allow: [], deny: [] } },
535
- allowedTools: [
536
- "Read", "Write", "Edit", "Bash", "Glob", "Grep",
537
- "WebSearch", "WebFetch", "AskUserQuestion",
538
- "Agent", "Skill", "TodoWrite", "ToolSearch",
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: { type: "preset", preset: "claude_code" },
650
+ systemPrompt: systemPromptOpt,
587
651
  settingSources: ["user", "project"],
588
652
  env: queryEnv,
589
653
  settings: { permissions: { allow: [], deny: [] } },
590
- allowedTools: [
591
- "Read", "Write", "Edit", "Bash", "Glob", "Grep",
592
- "WebSearch", "WebFetch", "AskUserQuestion",
593
- "Agent", "Skill", "TodoWrite", "ToolSearch",
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
- // Extract error detail from SDK result check multiple possible fields
832
- const sdkError = result.error ?? result.error_message ?? result.message ?? result.reason ?? "";
833
- const sdkDetail = typeof sdkError === "string" ? sdkError : JSON.stringify(sdkError);
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
- const fullMsg = sdkDetail ? `${baseMsg}\n${sdkDetail}` : baseMsg;
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: { type: "preset", preset: "claude_code" },
996
+ systemPrompt: systemPromptOpt,
898
997
  settingSources: ["user", "project"],
899
998
  env: queryEnv,
900
999
  settings: { permissions: { allow: [], deny: [] } },
901
- allowedTools: [
902
- "Read", "Write", "Edit", "Bash", "Glob", "Grep",
903
- "WebSearch", "WebFetch", "AskUserQuestion",
904
- "Agent", "Skill", "TodoWrite", "ToolSearch",
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
- yield { type: "error", message: r.error ?? `Agent stopped: ${r.subtype}` };
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) {
@@ -7,4 +7,5 @@ export type {
7
7
  ChatMessage,
8
8
  ToolApprovalHandler,
9
9
  UsageInfo,
10
+ SendMessageOpts,
10
11
  } from "../types/chat.ts";
@@ -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 data = await gitService.graphData(projectPath, max);
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 {
@@ -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
- const entry = activeSessions.get(sessionId);
373
- const providerId = entry?.providerId ?? providerRegistry.getDefault().id;
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
- if (entry) {
380
- ws.send(JSON.stringify({
381
- type: "status",
382
- sessionId,
383
- isStreaming: entry.isStreaming,
384
- pendingApproval: entry.pendingApprovalEvent ?? null,
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?.projectPath && provider && "ensureProjectPath" in provider) {
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?.isStreaming && entry.abort) {
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
- if (entry) {
425
- entry.streamPromise = new Promise<void>((resolve) => {
426
- setTimeout(() => {
427
- runStreamLoop(sessionId, providerId, parsed.content).then(resolve, resolve);
428
- }, 0);
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) return null;
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
- return accountService.getWithTokens(pickedId);
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) */