dr-claw 1.1.2 → 1.1.4

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 (113) hide show
  1. package/README.md +286 -177
  2. package/README.zh-CN.md +286 -174
  3. package/dist/assets/{_basePickBy-C0n1uo4u.js → _basePickBy-7CEpiVqc.js} +1 -1
  4. package/dist/assets/{_baseUniq-BRNmhFkc.js → _baseUniq-yh8GoKah.js} +1 -1
  5. package/dist/assets/{arc-DOMBmC_A.js → arc-xxYot-So.js} +1 -1
  6. package/dist/assets/{architectureDiagram-VXUJARFQ-CtMWglbD.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
  7. package/dist/assets/{blockDiagram-VD42YOAC-DK_gTkJy.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
  8. package/dist/assets/{c4Diagram-YG6GDRKO-DrDAvsn7.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
  9. package/dist/assets/channel-BJdHHkhR.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-CGbhdZcN.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-sGeFSxen.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
  12. package/dist/assets/{chunk-B4BG7PRW-RxgHAU_P.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
  13. package/dist/assets/{chunk-DI55MBZ5-CvHUXZrx.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-Sn7cXVNU.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
  15. package/dist/assets/{chunk-QN33PNHL-bDKOHppN.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-B9kN5LGm.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
  17. package/dist/assets/{chunk-TZMSLE5B-CCBe6len.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
  18. package/dist/assets/{classDiagram-2ON5EDUG-DQoMNYlY.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-WZHVMYZB-DQoMNYlY.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
  20. package/dist/assets/clone-CwXTbwxl.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-r6sLiHBW.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
  22. package/dist/assets/{dagre-6UL2VRFP-AbsaR1Oa.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
  23. package/dist/assets/{diagram-PSM6KHXK-DjCvdUyy.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
  24. package/dist/assets/{diagram-QEK2KX5R-CqiocXkH.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
  25. package/dist/assets/{diagram-S2PKOQOG-D5ALeoII.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
  26. package/dist/assets/{erDiagram-Q2GNP2WA-C1AeOsiz.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
  27. package/dist/assets/{flowDiagram-NV44I4VS-C98ve_DO.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
  28. package/dist/assets/{ganttDiagram-JELNMOA3-3zdLvcxD.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-V2S2FVAM-nDuDMsHh.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
  30. package/dist/assets/{graph-Blm-emkR.js → graph-DGtZXuDY.js} +1 -1
  31. package/dist/assets/index-C-sL-ag0.css +1 -0
  32. package/dist/assets/index-VdqxIFPL.js +670 -0
  33. package/dist/assets/{infoDiagram-HS3SLOUP-CmzE3q0R.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
  34. package/dist/assets/{journeyDiagram-XKPGCS4Q-CYaW7LrP.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
  35. package/dist/assets/{kanban-definition-3W4ZIXB7-mkmW7p0j.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
  36. package/dist/assets/{layout-Ct2kFIId.js → layout-Cnc-DCSb.js} +1 -1
  37. package/dist/assets/{linear-CKG9EwIJ.js → linear-BtRgmV19.js} +1 -1
  38. package/dist/assets/{mermaid.core-CaKcUnnd.js → mermaid.core-B20uzv6N.js} +5 -5
  39. package/dist/assets/{mindmap-definition-VGOIOE7T-Bct-pYin.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
  40. package/dist/assets/{pieDiagram-ADFJNKIX-C8wFD4Jr.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-AYHSOK5B-CuiE_f46.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
  42. package/dist/assets/{requirementDiagram-UZGBJVZJ-C4v1CqU8.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-TZEHDZUN-keAqqs-Y.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-WL72ISMW-ClNbvKTB.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
  45. package/dist/assets/{stateDiagram-FKZM4ZOC-D8We-2vq.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
  47. package/dist/assets/{timeline-definition-IT6M3QCI-DsvEMT5T.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
  48. package/dist/assets/{treemap-GDKQZRPO-TJrzN7Bj.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
  49. package/dist/assets/{xychartDiagram-PRI3JC2R-BdATmY5F.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
  50. package/dist/icons/nanoclaude.png +0 -0
  51. package/dist/index.html +2 -2
  52. package/dist/screenshots/screenshot_v1_2.png +0 -0
  53. package/package.json +61 -4
  54. package/public/icons/nanoclaude.png +0 -0
  55. package/public/screenshots/screenshot_v1_2.png +0 -0
  56. package/scripts/build-electron-icons.mjs +40 -0
  57. package/scripts/check-deps.js +28 -0
  58. package/scripts/native-runtime.mjs +128 -0
  59. package/server/__tests__/gemini-api.test.mjs +699 -0
  60. package/server/__tests__/gemini-session-index.test.mjs +95 -0
  61. package/server/__tests__/project-sync-dedup.test.mjs +173 -0
  62. package/server/__tests__/session-delete.test.mjs +152 -0
  63. package/server/claude-sdk.js +175 -14
  64. package/server/cli-chat.js +28 -17
  65. package/server/cli.js +48 -11
  66. package/server/data/news-log-arxiv.json +1 -0
  67. package/server/data/news-log-huggingface.json +1 -0
  68. package/server/data/news-results-arxiv.json +460 -0
  69. package/server/data/news-results-huggingface.json +817 -0
  70. package/server/data/research_interests_arxiv.json +50 -0
  71. package/server/data/research_interests_huggingface.json +4 -0
  72. package/server/database/db.js +101 -5
  73. package/server/database/init.sql +17 -1
  74. package/server/gemini-api.js +1838 -0
  75. package/server/gemini-cli.js +13 -2
  76. package/server/index.js +94 -16
  77. package/server/load-env.js +13 -1
  78. package/server/local-gpu.js +166 -24
  79. package/server/mcp-compute.js +223 -0
  80. package/server/middleware/auth.js +25 -6
  81. package/server/nano-claude-code.js +340 -0
  82. package/server/nanoSessionPaths.js +41 -0
  83. package/server/openai-codex.js +6 -2
  84. package/server/openrouter.js +37 -22
  85. package/server/projects.js +516 -110
  86. package/server/routes/agent.js +32 -7
  87. package/server/routes/auto-research.js +1 -1
  88. package/server/routes/claude-btw.js +55 -0
  89. package/server/routes/cli-auth.js +422 -58
  90. package/server/routes/commands.js +25 -1
  91. package/server/routes/community-tools.js +398 -0
  92. package/server/routes/compute.js +155 -0
  93. package/server/routes/git.js +9 -11
  94. package/server/routes/memory.js +143 -0
  95. package/server/routes/news.js +3 -0
  96. package/server/routes/skills.js +35 -0
  97. package/server/routes/user.js +3 -5
  98. package/server/utils/__tests__/safePath.test.js +93 -0
  99. package/server/utils/cliResolution.js +13 -1
  100. package/server/utils/codexSessionMessages.js +285 -0
  101. package/server/utils/geminiOAuth.js +333 -0
  102. package/server/utils/memoryPrompt.js +78 -0
  103. package/server/utils/safePath.js +51 -0
  104. package/server/utils/sessionIndex.js +2 -0
  105. package/server/utils/skillExpander.js +142 -0
  106. package/server/utils/spawnAsync.js +78 -0
  107. package/shared/modelConstants.js +19 -1
  108. package/dist/assets/channel-BFOE7OyC.js +0 -1
  109. package/dist/assets/clone-BulQSblH.js +0 -1
  110. package/dist/assets/index-B8Yutsnm.css +0 -1
  111. package/dist/assets/index-Cf6gyhc3.js +0 -654
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +0 -1
  113. package/scripts/install-python-tools.js +0 -64
@@ -12,6 +12,8 @@ import { buildTempAttachmentFilename, toPortableAtPath } from './utils/imageAtta
12
12
  import { splitLegacyGeminiThoughtContent } from '../shared/geminiThoughtParser.js';
13
13
  import { classifyError } from '../shared/errorClassifier.js';
14
14
  import { buildGeminiThinkingConfig } from '../shared/geminiThinkingSupport.js';
15
+ import { buildMemoryBlock } from './utils/memoryPrompt.js';
16
+ import { expandSkillCommand } from './utils/skillExpander.js';
15
17
 
16
18
  // Use cross-spawn on Windows for better command execution
17
19
  const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -606,13 +608,22 @@ export async function spawnGemini(command, options = {}, ws) {
606
608
  }
607
609
 
608
610
  if (command && command.trim()) {
611
+ // Expand /skill-name slash commands into full SKILL.md instructions
612
+ // so the Gemini model receives actionable procedures.
613
+ const expandedCommand = await expandSkillCommand(command.trim(), workingDir);
614
+
609
615
  const effectiveAttachments = attachments || images;
610
- const attachmentResult = await handleGeminiAttachments(command, effectiveAttachments, workingDir);
616
+ const attachmentResult = await handleGeminiAttachments(expandedCommand, effectiveAttachments, workingDir);
611
617
  tempFilePaths = attachmentResult.tempFilePaths;
612
618
  if (attachmentResult.tempDir) {
613
619
  tempDirs.push(attachmentResult.tempDir);
614
620
  }
615
- const effectivePrompt = `${attachmentResult.modifiedCommand}`;
621
+ // Memory is prepended to the user prompt because Gemini CLI uses --prompt flag
622
+ // and does not expose a separate system instruction API.
623
+ const memoryBlock = options.userId ? buildMemoryBlock(options.userId) : '';
624
+ const effectivePrompt = memoryBlock
625
+ ? `${memoryBlock}\n\n${attachmentResult.modifiedCommand}`
626
+ : `${attachmentResult.modifiedCommand}`;
616
627
  args.push('--prompt', effectivePrompt);
617
628
 
618
629
  // Keep Gemini CLI in yolo mode internally and enforce policy in our own approval hook.
package/server/index.js CHANGED
@@ -31,8 +31,6 @@ const c = {
31
31
  dim: (text) => `${colors.dim}${text}${colors.reset}`,
32
32
  };
33
33
 
34
- console.log('Requested PORT from env:', process.env.PORT);
35
-
36
34
  import express from 'express';
37
35
  import { WebSocketServer, WebSocket } from 'ws';
38
36
  import os from 'os';
@@ -52,6 +50,7 @@ import { queryCodex, abortCodexSession, isCodexSessionActive, getCodexSessionSta
52
50
  import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getGeminiSessionStartTime, getActiveGeminiSessions } from './gemini-cli.js';
53
51
  import { queryOpenRouter, abortOpenRouterSession, isOpenRouterSessionActive, getOpenRouterSessionStartTime, getActiveOpenRouterSessions } from './openrouter.js';
54
52
  import { queryLocalGPU, abortLocalGPUSession, isLocalGPUSessionActive, getLocalGPUSessionStartTime, getActiveLocalGPUSessions } from './local-gpu.js';
53
+ import { spawnNanoClaudeCode, abortNanoClaudeCodeSession, isNanoClaudeCodeSessionActive, getNanoClaudeCodeSessionStartTime, getActiveNanoClaudeCodeSessions } from './nano-claude-code.js';
55
54
  import gitRoutes from './routes/git.js';
56
55
  import authRoutes from './routes/auth.js';
57
56
  import mcpRoutes from './routes/mcp.js';
@@ -59,6 +58,7 @@ import cursorRoutes from './routes/cursor.js';
59
58
  import taskmasterRoutes from './routes/taskmaster.js';
60
59
  import mcpUtilsRoutes from './routes/mcp-utils.js';
61
60
  import commandsRoutes from './routes/commands.js';
61
+ import claudeBtwRoutes from './routes/claude-btw.js';
62
62
  import settingsRoutes from './routes/settings.js';
63
63
  import agentRoutes from './routes/agent.js';
64
64
  import projectsRoutes, { WORKSPACES_ROOT, getWorkspacesRoot, validateWorkspacePath } from './routes/projects.js';
@@ -86,13 +86,15 @@ import {
86
86
  setRuntimePortSync,
87
87
  } from './utils/runtimePorts.js';
88
88
  import { buildCodexTokenUsageFromJsonl } from './utils/sessionTokenUsage.js';
89
+ import { getNanoDrClawSessionsRoot } from './nanoSessionPaths.js';
89
90
 
90
91
  // File system watchers for provider project/session folders
91
92
  const PROVIDER_WATCH_PATHS = [
92
93
  { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
93
94
  { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
94
95
  { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
95
- { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }
96
+ { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'sessions') },
97
+ { provider: 'nano', rootPath: getNanoDrClawSessionsRoot() },
96
98
  ];
97
99
  const WATCHER_IGNORED_PATTERNS = [
98
100
  '**/node_modules/**',
@@ -132,6 +134,11 @@ function shouldProcessProjectsWatcherEvent(eventType, filePath, provider) {
132
134
  );
133
135
  }
134
136
 
137
+ if (provider === 'nano') {
138
+ const base = path.basename(String(filePath || '').replace(/\\/g, '/')).toLowerCase();
139
+ return base.endsWith('.json') && base.startsWith('drclaw-nano-');
140
+ }
141
+
135
142
  return true;
136
143
  }
137
144
 
@@ -298,6 +305,15 @@ const SHELL_EMBEDDED_ENV_KEYS_TO_REMOVE = [
298
305
  'WT_SESSION',
299
306
  ];
300
307
 
308
+ function safePtyKill(session, sessionKey) {
309
+ if (!session?.pty?.kill) return;
310
+ try {
311
+ session.pty.kill();
312
+ } catch (err) {
313
+ console.warn(`⚠️ Failed to kill PTY process (${sessionKey}):`, err);
314
+ }
315
+ }
316
+
301
317
  function stripAnsiSequences(value = '') {
302
318
  return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
303
319
  }
@@ -412,7 +428,14 @@ const wss = new WebSocketServer({
412
428
  // Make WebSocket server available to routes
413
429
  app.locals.wss = wss;
414
430
 
415
- app.use(cors());
431
+ // Parse VITE_PORT early so CORS reflects the actual frontend port.
432
+ const CORS_VITE_PORT = parsePortNumber(process.env.VITE_PORT, DEFAULT_FRONTEND_PORT);
433
+ app.use(cors({
434
+ origin: process.env.DR_CLAW_CORS_ORIGINS
435
+ ? process.env.DR_CLAW_CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
436
+ : [`http://localhost:${getFrontendPortSync(CORS_VITE_PORT)}`, `http://127.0.0.1:${getFrontendPortSync(CORS_VITE_PORT)}`],
437
+ credentials: true,
438
+ }));
416
439
  app.use(express.json({
417
440
  limit: '50mb',
418
441
  type: (req) => {
@@ -428,11 +451,21 @@ app.use(express.urlencoded({ limit: '50mb', extended: true }));
428
451
 
429
452
  // Public health check endpoint (no authentication required)
430
453
  app.get('/health', (req, res) => {
431
- res.json({
454
+ const isDesktop = process.env.DR_CLAW_DESKTOP === '1';
455
+ const response = {
432
456
  status: 'ok',
433
457
  timestamp: new Date().toISOString(),
434
- installMode
435
- });
458
+ installMode: isDesktop ? 'desktop' : installMode,
459
+ };
460
+
461
+ if (isDesktop) {
462
+ response.desktop = {
463
+ platform: process.platform,
464
+ arch: process.arch,
465
+ };
466
+ }
467
+
468
+ res.json(response);
436
469
  });
437
470
 
438
471
  // Optional API key validation (if configured)
@@ -462,6 +495,9 @@ app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
462
495
  // Commands API Routes (protected)
463
496
  app.use('/api/commands', authenticateToken, commandsRoutes);
464
497
 
498
+ // Claude ephemeral side question (/btw)
499
+ app.use('/api/claude', authenticateToken, claudeBtwRoutes);
500
+
465
501
  // Settings API Routes (protected)
466
502
  app.use('/api/settings', authenticateToken, settingsRoutes);
467
503
 
@@ -655,6 +691,13 @@ app.use(express.static(path.join(__dirname, '../dist'), {
655
691
  // System update endpoint
656
692
  app.post('/api/system/update', authenticateToken, async (req, res) => {
657
693
  try {
694
+ if (process.env.DR_CLAW_DESKTOP === '1') {
695
+ return res.status(400).json({
696
+ success: false,
697
+ error: 'Desktop builds do not support in-app self-update yet. Install a newer desktop package instead.'
698
+ });
699
+ }
700
+
658
701
  // Get the project root directory (parent of server directory)
659
702
  const projectRoot = path.join(__dirname, '..');
660
703
 
@@ -662,7 +705,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
662
705
 
663
706
  // Run the update command based on installation mode
664
707
  const updateCommand = installMode === 'git'
665
- ? 'git checkout main && git pull && npm install'
708
+ ? 'git stash && git checkout main && git pull && npm install'
666
709
  : `npm install -g ${npmPackageName}@latest`;
667
710
 
668
711
  const child = spawn('sh', ['-c', updateCommand], {
@@ -1662,6 +1705,35 @@ function handleChatConnection(ws, request) {
1662
1705
  queryLocalGPU(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1663
1706
  console.error('[ERROR] Local GPU query error:', error);
1664
1707
  });
1708
+ } else if (data.type === 'nano-command') {
1709
+ console.log('[DEBUG] Nano Claude Code message:', data.command || '[Continue/Resume]');
1710
+ console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1711
+ console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
1712
+ console.log('🤖 Model:', data.options?.model || 'default');
1713
+ const commandTelemetryEnabled = data.options?.telemetryEnabled !== false;
1714
+ const sessionId = data.options?.sessionId || data.sessionId;
1715
+
1716
+ if (sessionId && isNanoClaudeCodeSessionActive(sessionId)) {
1717
+ console.log(`[WARN] Nano Claude Code session ${sessionId} is already active. Ignoring concurrent request.`);
1718
+ return;
1719
+ }
1720
+
1721
+ enqueueConversationTelemetry(
1722
+ {
1723
+ name: 'agent_dialogue_meta',
1724
+ direction: 'user_to_agent',
1725
+ provider: 'nano',
1726
+ sessionId: sessionId || null,
1727
+ projectPath: data.options?.projectPath || data.options?.cwd || null,
1728
+ transportType: data.type,
1729
+ },
1730
+ { ...telemetryContext, telemetryEnabled: commandTelemetryEnabled },
1731
+ );
1732
+ writer.telemetryContext = { ...telemetryContext, provider: 'nano', telemetryEnabled: commandTelemetryEnabled };
1733
+ writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null);
1734
+ spawnNanoClaudeCode(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => {
1735
+ console.error('[ERROR] Nano Claude Code error:', error);
1736
+ });
1665
1737
  } else if (data.type === 'cursor-resume') {
1666
1738
  // Backward compatibility: treat as cursor-command with resume and no prompt
1667
1739
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
@@ -1695,6 +1767,8 @@ function handleChatConnection(ws, request) {
1695
1767
  success = abortOpenRouterSession(data.sessionId);
1696
1768
  } else if (provider === 'local') {
1697
1769
  success = abortLocalGPUSession(data.sessionId);
1770
+ } else if (provider === 'nano') {
1771
+ success = abortNanoClaudeCodeSession(data.sessionId);
1698
1772
  } else {
1699
1773
  // Use Claude Agents SDK
1700
1774
  success = await abortClaudeSDKSession(data.sessionId);
@@ -1749,6 +1823,9 @@ function handleChatConnection(ws, request) {
1749
1823
  } else if (provider === 'local') {
1750
1824
  isActive = isLocalGPUSessionActive(sessionId);
1751
1825
  startTime = getLocalGPUSessionStartTime(sessionId);
1826
+ } else if (provider === 'nano') {
1827
+ isActive = isNanoClaudeCodeSessionActive(sessionId);
1828
+ startTime = getNanoClaudeCodeSessionStartTime(sessionId);
1752
1829
  } else {
1753
1830
  // Use Claude Agents SDK
1754
1831
  isActive = isClaudeSDKSessionActive(sessionId);
@@ -1769,7 +1846,8 @@ function handleChatConnection(ws, request) {
1769
1846
  cursor: getActiveCursorSessions(),
1770
1847
  codex: getActiveCodexSessions(),
1771
1848
  gemini: getActiveGeminiSessions(),
1772
- local: getActiveLocalGPUSessions()
1849
+ local: getActiveLocalGPUSessions(),
1850
+ nano: getActiveNanoClaudeCodeSessions()
1773
1851
  };
1774
1852
 
1775
1853
  writer.send({
@@ -1840,7 +1918,7 @@ function handleShellConnection(ws) {
1840
1918
  if (oldSession) {
1841
1919
  console.log('🧹 Cleaning up existing login session:', ptySessionKey);
1842
1920
  if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
1843
- if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
1921
+ safePtyKill(oldSession, ptySessionKey);
1844
1922
  ptySessionsMap.delete(ptySessionKey);
1845
1923
  }
1846
1924
  }
@@ -2172,9 +2250,7 @@ function handleShellConnection(ws) {
2172
2250
 
2173
2251
  session.timeoutId = setTimeout(() => {
2174
2252
  console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
2175
- if (session.pty && session.pty.kill) {
2176
- session.pty.kill();
2177
- }
2253
+ safePtyKill(session, ptySessionKey);
2178
2254
  ptySessionsMap.delete(ptySessionKey);
2179
2255
  }, PTY_SESSION_TIMEOUT);
2180
2256
  }
@@ -2390,9 +2466,7 @@ function handleComputeShellConnection(ws, urlNodeId) {
2390
2466
  session.ws = null;
2391
2467
  session.timeoutId = setTimeout(() => {
2392
2468
  console.log('⏰ Compute PTY session timeout, killing:', ptySessionKey);
2393
- if (session.pty && session.pty.kill) {
2394
- session.pty.kill();
2395
- }
2469
+ safePtyKill(session, ptySessionKey);
2396
2470
  ptySessionsMap.delete(ptySessionKey);
2397
2471
  }, PTY_SESSION_TIMEOUT);
2398
2472
  }
@@ -3088,6 +3162,7 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
3088
3162
  const REQUESTED_PORT = parsePortNumber(process.env.PORT, DEFAULT_BACKEND_PORT);
3089
3163
  const REQUESTED_VITE_PORT = parsePortNumber(process.env.VITE_PORT, DEFAULT_FRONTEND_PORT);
3090
3164
  const HOST = process.env.HOST || '0.0.0.0';
3165
+ const IS_DESKTOP = process.env.DR_CLAW_DESKTOP === '1';
3091
3166
  // Show localhost when binding to all interfaces; 0.0.0.0 is not directly connectable.
3092
3167
  const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
3093
3168
 
@@ -3103,6 +3178,9 @@ async function startServer() {
3103
3178
 
3104
3179
  // Log Claude implementation mode
3105
3180
  console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
3181
+ if (IS_DESKTOP) {
3182
+ console.log(`${c.info('[INFO]')} Running inside Electron desktop shell`);
3183
+ }
3106
3184
  console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
3107
3185
 
3108
3186
  if (!isProduction) {
@@ -42,6 +42,18 @@ function resolveDefaultDatabasePath() {
42
42
  }
43
43
  }
44
44
 
45
+ function stripOptionalEnvQuotes(raw) {
46
+ const v = String(raw).trim();
47
+ if (v.length >= 2) {
48
+ const open = v[0];
49
+ const close = v[v.length - 1];
50
+ if ((open === '"' && close === '"') || (open === "'" && close === "'")) {
51
+ return v.slice(1, -1);
52
+ }
53
+ }
54
+ return v;
55
+ }
56
+
45
57
  try {
46
58
  const envPath = path.join(__dirname, '../.env');
47
59
  const envFile = fs.readFileSync(envPath, 'utf8');
@@ -50,7 +62,7 @@ try {
50
62
  if (trimmedLine && !trimmedLine.startsWith('#')) {
51
63
  const [key, ...valueParts] = trimmedLine.split('=');
52
64
  if (key && valueParts.length > 0 && !process.env[key]) {
53
- process.env[key] = valueParts.join('=').trim();
65
+ process.env[key] = stripOptionalEnvQuotes(valueParts.join('='));
54
66
  }
55
67
  }
56
68
  });
@@ -11,20 +11,26 @@ import crypto from 'crypto';
11
11
  import { promises as fs } from 'fs';
12
12
  import path from 'path';
13
13
  import os from 'os';
14
- import { exec } from 'child_process';
14
+ import { exec, execFile } from 'child_process';
15
15
  import { promisify } from 'util';
16
+ import { glob } from 'glob';
16
17
  import { encodeProjectPath, ensureProjectSkillLinks, reconcileLocalGPUSessionIndex } from './projects.js';
17
18
  import { writeProjectTemplates } from './templates/index.js';
18
19
  import { classifyError } from '../shared/errorClassifier.js';
19
20
  import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js';
20
21
  import { createRequestId, waitForToolApproval, matchesToolPermission } from './utils/permissions.js';
22
+ import { buildMemoryBlock } from './utils/memoryPrompt.js';
21
23
 
22
24
  const execAsync = promisify(exec);
25
+ const execFileAsync = promisify(execFile);
23
26
 
24
- const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
27
+ export const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
25
28
  const MAX_AGENT_TURNS = 30;
26
29
  const BASH_TIMEOUT_MS = 120_000;
27
30
  const MAX_OUTPUT_CHARS = 100_000;
31
+ /** Match maxBuffer to capped output so large command output is not buffered in full. */
32
+ const MAX_TOOL_BUFFER_BYTES = Math.min(MAX_OUTPUT_CHARS * 4, 512 * 1024);
33
+ const GLOB_MAX_MATCHES = 200;
28
34
  const SESSIONS_DIR_NAME = 'localgpu-sessions';
29
35
 
30
36
  const activeLocalGPUSessions = new Map();
@@ -93,12 +99,58 @@ export async function detectGPUs() {
93
99
  // Ollama helpers
94
100
  // ---------------------------------------------------------------------------
95
101
 
102
+ function isLoopbackHostname(hostname) {
103
+ if (!hostname) return false;
104
+ const h = hostname.toLowerCase();
105
+ if (h === 'localhost' || h === '::1' || h === '[::1]') return true;
106
+ if (h === '127.0.0.1') return true;
107
+ const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
108
+ if (!m) return false;
109
+ return [m[1], m[2], m[3]].every((oct) => {
110
+ const n = parseInt(oct, 10);
111
+ return n >= 0 && n <= 255;
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Normalize and validate Ollama base URL. Only http(s) loopback hosts are allowed (SSRF hardening).
117
+ */
118
+ export function normalizeLocalOllamaBaseUrl(input) {
119
+ let raw = String(input ?? '').trim().replace(/\/+$/, '');
120
+ if (!raw) raw = DEFAULT_OLLAMA_URL;
121
+ if (!/^https?:\/\//i.test(raw)) {
122
+ raw = `http://${raw}`;
123
+ }
124
+ let u;
125
+ try {
126
+ u = new URL(raw);
127
+ } catch {
128
+ throw new Error('Invalid Ollama server URL');
129
+ }
130
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
131
+ throw new Error('Ollama URL must use http or https');
132
+ }
133
+ if (u.username || u.password) {
134
+ throw new Error('Ollama URL must not include credentials');
135
+ }
136
+ if (!isLoopbackHostname(u.hostname)) {
137
+ throw new Error('Ollama URL must use localhost or 127.0.0.1 (loopback only)');
138
+ }
139
+ return u.origin;
140
+ }
141
+
96
142
  function getOllamaUrl(options) {
97
- return (options?.serverUrl || process.env.LOCAL_GPU_SERVER_URL || DEFAULT_OLLAMA_URL).replace(/\/+$/, '');
143
+ const raw = options?.serverUrl || process.env.LOCAL_GPU_SERVER_URL || DEFAULT_OLLAMA_URL;
144
+ return normalizeLocalOllamaBaseUrl(raw);
98
145
  }
99
146
 
100
147
  export async function checkOllamaStatus(serverUrl) {
101
- const url = (serverUrl || DEFAULT_OLLAMA_URL).replace(/\/+$/, '');
148
+ let url;
149
+ try {
150
+ url = normalizeLocalOllamaBaseUrl(serverUrl || DEFAULT_OLLAMA_URL);
151
+ } catch (e) {
152
+ return { running: false, error: e.message };
153
+ }
102
154
  try {
103
155
  const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(5000) });
104
156
  if (!res.ok) return { running: false, error: `Server returned ${res.status}` };
@@ -129,7 +181,7 @@ function formatOllamaModel(m) {
129
181
  }
130
182
 
131
183
  export async function pullOllamaModel(serverUrl, modelName) {
132
- const url = (serverUrl || DEFAULT_OLLAMA_URL).replace(/\/+$/, '');
184
+ const url = normalizeLocalOllamaBaseUrl(serverUrl || DEFAULT_OLLAMA_URL);
133
185
  const res = await fetch(`${url}/api/pull`, {
134
186
  method: 'POST',
135
187
  headers: { 'Content-Type': 'application/json' },
@@ -279,6 +331,78 @@ function sendMessage(ws, data) {
279
331
  // Tool execution (same as OpenRouter)
280
332
  // ---------------------------------------------------------------------------
281
333
 
334
+ async function runBashToolCommand(command, execCwd) {
335
+ if (typeof command !== 'string') {
336
+ return 'Error: command must be a string';
337
+ }
338
+ if (command.includes('\0')) {
339
+ return 'Error: command contains invalid characters';
340
+ }
341
+ const opts = {
342
+ cwd: execCwd,
343
+ timeout: BASH_TIMEOUT_MS,
344
+ maxBuffer: MAX_TOOL_BUFFER_BYTES,
345
+ windowsHide: true,
346
+ };
347
+ try {
348
+ if (process.platform === 'win32') {
349
+ const comspec = process.env.ComSpec || 'cmd.exe';
350
+ const { stdout, stderr } = await execFileAsync(comspec, ['/d', '/s', '/c', command], opts);
351
+ return (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).slice(0, MAX_OUTPUT_CHARS);
352
+ }
353
+ const { stdout, stderr } = await execFileAsync('/bin/sh', ['-c', command], opts);
354
+ return (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).slice(0, MAX_OUTPUT_CHARS);
355
+ } catch (err) {
356
+ return `Error: ${err.message}`;
357
+ }
358
+ }
359
+
360
+ function isPathUnderSearchRoot(absPath, rootAbs) {
361
+ const rootNorm = path.resolve(rootAbs);
362
+ const fileNorm = path.resolve(absPath);
363
+ const prefix = rootNorm.endsWith(path.sep) ? rootNorm : `${rootNorm}${path.sep}`;
364
+ return fileNorm === rootNorm || fileNorm.startsWith(prefix);
365
+ }
366
+
367
+ async function runRipgrepSearch(pattern, dirAbs, includeGlob) {
368
+ if (typeof pattern !== 'string' || pattern.includes('\0')) {
369
+ return 'Error: invalid search pattern';
370
+ }
371
+ const rgArgs = [
372
+ '--max-count', '50',
373
+ '--line-number',
374
+ '--color', 'never',
375
+ '--no-heading',
376
+ ];
377
+ if (includeGlob && typeof includeGlob === 'string' && !includeGlob.includes('\0')) {
378
+ rgArgs.push('--glob', includeGlob);
379
+ }
380
+ rgArgs.push('--', pattern, dirAbs);
381
+ const opts = {
382
+ cwd: dirAbs,
383
+ timeout: 15_000,
384
+ maxBuffer: MAX_TOOL_BUFFER_BYTES,
385
+ windowsHide: true,
386
+ };
387
+ try {
388
+ const { stdout, stderr } = await execFileAsync('rg', rgArgs, opts);
389
+ const combined = (stdout || '') + (stderr ? `\n[stderr]\n${stderr}` : '');
390
+ const trimmed = combined.trim().slice(0, MAX_OUTPUT_CHARS);
391
+ return trimmed || 'No results.';
392
+ } catch (err) {
393
+ if (err?.code === 'ENOENT') {
394
+ return 'Error: ripgrep (rg) is not installed or not in PATH. Install from https://github.com/BurntSushi/ripgrep';
395
+ }
396
+ // ripgrep exits 1 when there are zero matches (not an error).
397
+ if (err?.code === 1 && typeof err.stdout === 'string') {
398
+ const combined = (err.stdout || '') + (err.stderr ? `\n[stderr]\n${err.stderr}` : '');
399
+ const trimmed = combined.trim().slice(0, MAX_OUTPUT_CHARS);
400
+ return trimmed || 'No results.';
401
+ }
402
+ return `Error: ${err.message}`;
403
+ }
404
+ }
405
+
282
406
  async function executeTool(name, args, cwd) {
283
407
  try {
284
408
  if (name === 'Read') {
@@ -313,35 +437,40 @@ async function executeTool(name, args, cwd) {
313
437
 
314
438
  if (name === 'Bash') {
315
439
  const execCwd = args.cwd ? (path.isAbsolute(args.cwd) ? args.cwd : path.resolve(cwd, args.cwd)) : cwd;
316
- const { stdout, stderr } = await execAsync(args.command, {
317
- cwd: execCwd,
318
- timeout: BASH_TIMEOUT_MS,
319
- maxBuffer: 10 * 1024 * 1024,
320
- });
321
- return (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).slice(0, MAX_OUTPUT_CHARS);
440
+ return await runBashToolCommand(args.command, execCwd);
322
441
  }
323
442
 
324
443
  if (name === 'Glob') {
325
444
  const dir = args.directory
326
445
  ? (path.isAbsolute(args.directory) ? args.directory : path.resolve(cwd, args.directory))
327
446
  : cwd;
328
- const { stdout } = await execAsync(
329
- `find ${JSON.stringify(dir)} -path ${JSON.stringify(args.pattern)} -type f 2>/dev/null | head -200`,
330
- { cwd: dir, timeout: 15000 },
331
- );
332
- return stdout.trim() || 'No files matched.';
447
+ const dirResolved = path.resolve(dir);
448
+ if (typeof args.pattern !== 'string' || args.pattern.includes('\0')) {
449
+ return 'Error: invalid glob pattern';
450
+ }
451
+ const matches = await glob(args.pattern, {
452
+ cwd: dirResolved,
453
+ nodir: true,
454
+ absolute: true,
455
+ followSymbolicLinks: false,
456
+ maxDepth: 40,
457
+ });
458
+ const safe = [];
459
+ for (const p of matches) {
460
+ if (isPathUnderSearchRoot(p, dirResolved)) {
461
+ safe.push(p);
462
+ }
463
+ if (safe.length >= GLOB_MAX_MATCHES) break;
464
+ }
465
+ return safe.join('\n') || 'No files matched.';
333
466
  }
334
467
 
335
468
  if (name === 'Search') {
336
469
  const dir = args.directory
337
470
  ? (path.isAbsolute(args.directory) ? args.directory : path.resolve(cwd, args.directory))
338
471
  : cwd;
339
- const includeFlag = args.include ? `--glob ${JSON.stringify(args.include)}` : '';
340
- const { stdout } = await execAsync(
341
- `rg --max-count 50 --line-number ${includeFlag} ${JSON.stringify(args.pattern)} ${JSON.stringify(dir)} 2>/dev/null | head -200`,
342
- { cwd: dir, timeout: 15000 },
343
- );
344
- return stdout.trim() || 'No results.';
472
+ const dirResolved = path.resolve(dir);
473
+ return await runRipgrepSearch(args.pattern, dirResolved, args.include);
345
474
  }
346
475
 
347
476
  if (name === 'LS') {
@@ -556,7 +685,19 @@ export async function queryLocalGPU(command, options = {}, ws) {
556
685
  } = options;
557
686
 
558
687
  const workingDirectory = cwd || projectPath || process.cwd();
559
- const ollamaUrl = getOllamaUrl(options);
688
+ let ollamaUrl;
689
+ try {
690
+ ollamaUrl = getOllamaUrl(options);
691
+ } catch (err) {
692
+ sendMessage(ws, {
693
+ type: 'localgpu-error',
694
+ error: err.message,
695
+ errorType: 'auth',
696
+ isRetryable: false,
697
+ sessionId,
698
+ });
699
+ return;
700
+ }
560
701
 
561
702
  // Verify Ollama is reachable
562
703
  try {
@@ -623,7 +764,8 @@ export async function queryLocalGPU(command, options = {}, ws) {
623
764
  projectName: workingDirectory ? encodeProjectPath(workingDirectory) : undefined,
624
765
  });
625
766
 
626
- const systemContent = customSystemPrompt || await buildSystemPrompt(workingDirectory);
767
+ const memoryBlock = options.userId ? buildMemoryBlock(options.userId) : '';
768
+ const systemContent = (customSystemPrompt || await buildSystemPrompt(workingDirectory)) + memoryBlock;
627
769
  const messages = [{ role: 'system', content: systemContent }];
628
770
 
629
771
  if (sessionId) {