neural-loom 0.2.69 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/cache/.previewinfo +1 -1
  4. package/.next/cache/.rscinfo +1 -1
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/diagnostics/route-bundle-stats.json +11 -11
  7. package/.next/fallback-build-manifest.json +3 -3
  8. package/.next/next-server.js.nft.json +1 -1
  9. package/.next/prerender-manifest.json +3 -3
  10. package/.next/required-server-files.js +20 -20
  11. package/.next/required-server-files.json +20 -20
  12. package/.next/server/app/_global-error.html +1 -1
  13. package/.next/server/app/_global-error.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  16. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  17. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  18. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.html +1 -1
  20. package/.next/server/app/_not-found.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  25. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  26. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/.next/server/app/api/configs/route.js.nft.json +1 -1
  28. package/.next/server/app/api/context/aider/route.js.nft.json +1 -1
  29. package/.next/server/app/api/context/claude/route.js.nft.json +1 -1
  30. package/.next/server/app/api/context/route.js.nft.json +1 -1
  31. package/.next/server/app/api/context/ssh/route.js.nft.json +1 -1
  32. package/.next/server/app/api/files/route.js +1 -1
  33. package/.next/server/app/api/files/route.js.nft.json +1 -1
  34. package/.next/server/app/api/recents/route.js.nft.json +1 -1
  35. package/.next/server/app/api/sessions/inject/route.js.nft.json +1 -1
  36. package/.next/server/app/api/sessions/input/route.js.nft.json +1 -1
  37. package/.next/server/app/api/sessions/launch/route.js.nft.json +1 -1
  38. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  39. package/.next/server/app/api/sessions/scope/route.js.nft.json +1 -1
  40. package/.next/server/app/api/sessions/start/route.js.nft.json +1 -1
  41. package/.next/server/app/api/sessions/stats/route.js.nft.json +1 -1
  42. package/.next/server/app/api/sessions/stop/route.js.nft.json +1 -1
  43. package/.next/server/app/api/sessions/upload-image/route.js.nft.json +1 -1
  44. package/.next/server/app/api/sql/connections/route.js.nft.json +1 -1
  45. package/.next/server/app/api/sql/mcp/route.js +1 -1
  46. package/.next/server/app/api/sql/mcp/route.js.nft.json +1 -1
  47. package/.next/server/app/api/sql/query/route.js +1 -1
  48. package/.next/server/app/api/sql/query/route.js.nft.json +1 -1
  49. package/.next/server/app/api/sql/schema/route.js.nft.json +1 -1
  50. package/.next/server/app/api/telemetry/route.js +1 -1
  51. package/.next/server/app/api/telemetry/route.js.nft.json +1 -1
  52. package/.next/server/app/index.html +1 -1
  53. package/.next/server/app/index.rsc +2 -2
  54. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  55. package/.next/server/app/index.segments/_full.segment.rsc +2 -2
  56. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  57. package/.next/server/app/index.segments/_index.segment.rsc +1 -1
  58. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  59. package/.next/server/app/page/react-loadable-manifest.json +3 -3
  60. package/.next/server/app/page_client-reference-manifest.js +1 -1
  61. package/.next/server/chunks/[root-of-the-server]__0.hmw1f._.js +3 -0
  62. package/.next/server/chunks/[root-of-the-server]__0.hmw1f._.js.map +1 -0
  63. package/.next/server/chunks/[root-of-the-server]__00-sqou._.js +2 -2
  64. package/.next/server/chunks/[root-of-the-server]__00-sqou._.js.map +1 -1
  65. package/.next/server/chunks/[root-of-the-server]__00l5btc._.js +4 -4
  66. package/.next/server/chunks/[root-of-the-server]__00l5btc._.js.map +1 -1
  67. package/.next/server/chunks/[root-of-the-server]__06men8g._.js +4 -4
  68. package/.next/server/chunks/[root-of-the-server]__06men8g._.js.map +1 -1
  69. package/.next/server/chunks/[root-of-the-server]__097vtn1._.js +2 -2
  70. package/.next/server/chunks/[root-of-the-server]__097vtn1._.js.map +1 -1
  71. package/.next/server/chunks/[root-of-the-server]__0_v2.a8._.js +2 -2
  72. package/.next/server/chunks/[root-of-the-server]__0_v2.a8._.js.map +1 -1
  73. package/.next/server/chunks/{[root-of-the-server]__0.ptwor._.js → [root-of-the-server]__0ajln56._.js} +7 -7
  74. package/.next/server/chunks/[root-of-the-server]__0ajln56._.js.map +1 -0
  75. package/.next/server/chunks/{[root-of-the-server]__0co.g5k._.js → [root-of-the-server]__0auh-2z._.js} +5 -5
  76. package/.next/server/chunks/[root-of-the-server]__0auh-2z._.js.map +1 -0
  77. package/.next/server/chunks/[root-of-the-server]__0e03ite._.js +2 -2
  78. package/.next/server/chunks/[root-of-the-server]__0e03ite._.js.map +1 -1
  79. package/.next/server/chunks/[root-of-the-server]__0fxtx7z._.js +2 -2
  80. package/.next/server/chunks/[root-of-the-server]__0fxtx7z._.js.map +1 -1
  81. package/.next/server/chunks/[root-of-the-server]__0h.~hv6._.js +2 -2
  82. package/.next/server/chunks/[root-of-the-server]__0h.~hv6._.js.map +1 -1
  83. package/.next/server/chunks/[root-of-the-server]__0i83~95._.js +1 -1
  84. package/.next/server/chunks/[root-of-the-server]__0li1h1u._.js +19 -19
  85. package/.next/server/chunks/[root-of-the-server]__0li1h1u._.js.map +1 -1
  86. package/.next/server/chunks/[root-of-the-server]__0s1axek._.js +5 -5
  87. package/.next/server/chunks/[root-of-the-server]__0s1axek._.js.map +1 -1
  88. package/.next/server/chunks/[root-of-the-server]__0t713sv._.js +1 -1
  89. package/.next/server/chunks/[root-of-the-server]__0t713sv._.js.map +1 -1
  90. package/.next/server/chunks/[root-of-the-server]__0tq0maq._.js +5 -5
  91. package/.next/server/chunks/[root-of-the-server]__0tq0maq._.js.map +1 -1
  92. package/.next/server/chunks/[root-of-the-server]__0viil6b._.js +2 -2
  93. package/.next/server/chunks/[root-of-the-server]__0viil6b._.js.map +1 -1
  94. package/.next/server/chunks/[root-of-the-server]__0ykd4~u._.js +3 -0
  95. package/.next/server/chunks/[root-of-the-server]__0ykd4~u._.js.map +1 -0
  96. package/.next/server/chunks/[root-of-the-server]__11h_nxt._.js +1 -1
  97. package/.next/server/chunks/[root-of-the-server]__11h_nxt._.js.map +1 -1
  98. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_016qk~0.js +2 -2
  99. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_016qk~0.js.map +1 -1
  100. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0ev3h.z.js +1 -1
  101. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0ev3h.z.js.map +1 -1
  102. package/.next/server/edge/chunks/[root-of-the-server]__08ca2jv._.js +11 -0
  103. package/.next/server/edge/chunks/[root-of-the-server]__08ca2jv._.js.map +1 -0
  104. package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0kvehva.js +3 -0
  105. package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0kvehva.js.map +1 -0
  106. package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0lcpsxv.js.map +1 -0
  107. package/.next/server/edge/chunks/turbopack-node_modules_next_dist_esm_build_templates_edge-wrapper_0lcpsxv.js +3 -0
  108. package/.next/server/middleware/middleware-manifest.json +32 -0
  109. package/.next/server/middleware-build-manifest.js +3 -3
  110. package/.next/server/middleware-manifest.json +30 -2
  111. package/.next/server/pages/404.html +1 -1
  112. package/.next/server/pages/500.html +1 -1
  113. package/.next/server/server-reference-manifest.js +1 -1
  114. package/.next/server/server-reference-manifest.json +1 -1
  115. package/.next/static/04j4ht7eCOOXSLQ5Wixq0/_clientMiddlewareManifest.js +6 -0
  116. package/.next/static/chunks/{008bf~h8rh5_3.js → 0uyuco-451j.n.js} +2 -2
  117. package/.next/static/chunks/11c6f8pb_6s91.js +4 -0
  118. package/.next/static/chunks/{00pgauqedvo01.js → 14~-ojwsxf~oe.js} +4 -4
  119. package/.next/trace +1 -1
  120. package/.next/trace-build +1 -1
  121. package/README.md +18 -0
  122. package/bin/cli.js +12 -2
  123. package/package.json +9 -6
  124. package/src/app/api/files/route.ts +30 -0
  125. package/src/app/api/git/route.ts +46 -31
  126. package/src/app/api/sessions/stats/route.ts +40 -22
  127. package/src/app/api/sql/query/route.ts +2 -15
  128. package/src/app/api/telemetry/route.ts +2 -1
  129. package/src/app/components/MarkdownPreview.tsx +17 -10
  130. package/src/app/components/TerminalConsole.tsx +99 -21
  131. package/src/lib/agents/AgentRunner.ts +41 -5
  132. package/src/lib/agents/AiderRunner.ts +20 -36
  133. package/src/lib/agents/ClaudeRunner.ts +8 -62
  134. package/src/lib/agents/DockerRunner.ts +18 -54
  135. package/src/lib/agents/MockRunner.ts +3 -1
  136. package/src/lib/agents/SSHRunner.ts +8 -54
  137. package/src/lib/agents/SessionPersister.ts +11 -1
  138. package/src/lib/agents/ShellRunner.ts +10 -46
  139. package/src/lib/agents/WebSocketServer.ts +17 -2
  140. package/src/lib/agents/pricing.ts +45 -0
  141. package/src/lib/agents/runtime.ts +92 -0
  142. package/src/lib/gitSafe.ts +15 -0
  143. package/src/lib/security.ts +76 -0
  144. package/src/lib/sql/McpServer.ts +2 -11
  145. package/src/lib/sql/adapters/MssqlAdapter.ts +16 -1
  146. package/src/lib/sql/adapters/MysqlAdapter.ts +23 -1
  147. package/src/lib/sql/adapters/PostgresAdapter.ts +22 -1
  148. package/src/lib/sql/adapters/SqliteAdapter.ts +21 -10
  149. package/src/lib/sql/sqlGuard.ts +20 -0
  150. package/src/lib/workspaceParser.ts +23 -6
  151. package/src/middleware.ts +27 -0
  152. package/.next/server/chunks/[externals]__048y_op._.js +0 -3
  153. package/.next/server/chunks/[externals]__048y_op._.js.map +0 -1
  154. package/.next/server/chunks/[root-of-the-server]__0.ptwor._.js.map +0 -1
  155. package/.next/server/chunks/[root-of-the-server]__08r0s-s._.js +0 -3
  156. package/.next/server/chunks/[root-of-the-server]__08r0s-s._.js.map +0 -1
  157. package/.next/server/chunks/[root-of-the-server]__0co.g5k._.js.map +0 -1
  158. package/.next/static/EglcK14vXeSWIjxjJCP9A/_clientMiddlewareManifest.js +0 -1
  159. package/.next/static/chunks/0ii_nb0g40rmj.js +0 -4
  160. /package/.next/static/{EglcK14vXeSWIjxjJCP9A → 04j4ht7eCOOXSLQ5Wixq0}/_buildManifest.js +0 -0
  161. /package/.next/static/{EglcK14vXeSWIjxjJCP9A → 04j4ht7eCOOXSLQ5Wixq0}/_ssgManifest.js +0 -0
@@ -1,10 +1,22 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { promisify } from "util";
3
- import { exec } from "child_process";
3
+ import { execFile } from "child_process";
4
4
  import fs from "fs";
5
5
  import path from "path";
6
+ import { isValidBranchName } from "@/lib/gitSafe";
6
7
 
7
- const execPromise = promisify(exec);
8
+ const execFilePromise = promisify(execFile);
9
+
10
+ // Run git without a shell. Arguments are passed as an array, so values like file
11
+ // names, branch names and commit messages cannot break out into shell commands.
12
+ async function git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
13
+ const { stdout, stderr } = await execFilePromise("git", args, {
14
+ cwd,
15
+ maxBuffer: 1024 * 1024 * 20,
16
+ windowsHide: true,
17
+ });
18
+ return { stdout: stdout.toString(), stderr: stderr.toString() };
19
+ }
8
20
 
9
21
  export async function GET(request: Request) {
10
22
  try {
@@ -17,13 +29,13 @@ export async function GET(request: Request) {
17
29
 
18
30
  // Check if it's a git repo
19
31
  try {
20
- await execPromise("git rev-parse --is-inside-work-tree", { cwd: targetRoot });
32
+ await git(["rev-parse", "--is-inside-work-tree"], targetRoot);
21
33
  } catch {
22
34
  return NextResponse.json({ success: true, isGit: false, files: [] });
23
35
  }
24
36
 
25
37
  if (action === "status") {
26
- const { stdout } = await execPromise("git status --porcelain", { cwd: targetRoot });
38
+ const { stdout } = await git(["status", "--porcelain"], targetRoot);
27
39
  const lines = stdout.split(/\r?\n/).filter(Boolean);
28
40
  const files = lines.map(line => {
29
41
  // porcelain format: XY path or XY "path"
@@ -49,7 +61,7 @@ export async function GET(request: Request) {
49
61
  // Get branch name
50
62
  let branch = "";
51
63
  try {
52
- const { stdout: branchOut } = await execPromise("git rev-parse --abbrev-ref HEAD", { cwd: targetRoot });
64
+ const { stdout: branchOut } = await git(["rev-parse", "--abbrev-ref", "HEAD"], targetRoot);
53
65
  branch = branchOut.trim();
54
66
  } catch {}
55
67
 
@@ -57,7 +69,7 @@ export async function GET(request: Request) {
57
69
  let ahead = 0;
58
70
  let behind = 0;
59
71
  try {
60
- const { stdout: syncOut } = await execPromise("git rev-list --left-right --count HEAD...@{u}", { cwd: targetRoot });
72
+ const { stdout: syncOut } = await git(["rev-list", "--left-right", "--count", "HEAD...@{u}"], targetRoot);
61
73
  const parts = syncOut.trim().split(/\s+/);
62
74
  if (parts.length === 2) {
63
75
  ahead = parseInt(parts[0], 10) || 0;
@@ -79,7 +91,7 @@ export async function GET(request: Request) {
79
91
  }
80
92
 
81
93
  // Check status to see if it is untracked
82
- const { stdout: statusOut } = await execPromise("git status --porcelain -- " + JSON.stringify(file), { cwd: targetRoot });
94
+ const { stdout: statusOut } = await git(["status", "--porcelain", "--", file], targetRoot);
83
95
  const isUntracked = statusOut.startsWith("??");
84
96
 
85
97
  if (isUntracked) {
@@ -88,7 +100,7 @@ export async function GET(request: Request) {
88
100
  return NextResponse.json({ success: true, diff: diffLines });
89
101
  } else {
90
102
  try {
91
- const { stdout: diffOut } = await execPromise(`git diff --no-color -- ${JSON.stringify(file)}`, { cwd: targetRoot });
103
+ const { stdout: diffOut } = await git(["diff", "--no-color", "--", file], targetRoot);
92
104
  return NextResponse.json({ success: true, diff: diffOut });
93
105
  } catch {
94
106
  return NextResponse.json({ success: true, diff: `Binary file or diff unavailable.` });
@@ -97,7 +109,7 @@ export async function GET(request: Request) {
97
109
  }
98
110
 
99
111
  if (action === "branches") {
100
- const { stdout } = await execPromise("git branch --format=\"%(refname:short)|%(HEAD)\"", { cwd: targetRoot });
112
+ const { stdout } = await git(["branch", "--format=%(refname:short)|%(HEAD)"], targetRoot);
101
113
  const branches = stdout.split(/\r?\n/).filter(Boolean).map(line => {
102
114
  const [name, active] = line.split("|");
103
115
  return {
@@ -110,7 +122,10 @@ export async function GET(request: Request) {
110
122
 
111
123
  if (action === "log") {
112
124
  try {
113
- const { stdout } = await execPromise("git log --graph --abbrev-commit --decorate --format=format:\"%h||%p||%s||%an||%ar\" --all -n 100", { cwd: targetRoot });
125
+ const { stdout } = await git(
126
+ ["log", "--graph", "--abbrev-commit", "--decorate", "--format=format:%h||%p||%s||%an||%ar", "--all", "-n", "100"],
127
+ targetRoot
128
+ );
114
129
  const lines = stdout.split(/\r?\n/);
115
130
  const logItems = lines.map(line => {
116
131
  const parts = line.split("||");
@@ -119,7 +134,7 @@ export async function GET(request: Request) {
119
134
  const hashMatch = firstPart.match(/([0-9a-f]{7,40})$/);
120
135
  const hash = hashMatch ? hashMatch[1] : "";
121
136
  const graph = firstPart.substring(0, firstPart.length - hash.length);
122
-
137
+
123
138
  return {
124
139
  isCommit: true,
125
140
  graph,
@@ -142,7 +157,7 @@ export async function GET(request: Request) {
142
157
  }
143
158
  });
144
159
  return NextResponse.json({ success: true, log: logItems });
145
- } catch (err) {
160
+ } catch {
146
161
  return NextResponse.json({ success: true, log: [] });
147
162
  }
148
163
  }
@@ -163,25 +178,25 @@ export async function POST(request: Request) {
163
178
 
164
179
  // Check if it's a git repo
165
180
  try {
166
- await execPromise("git rev-parse --is-inside-work-tree", { cwd: targetRoot });
181
+ await git(["rev-parse", "--is-inside-work-tree"], targetRoot);
167
182
  } catch {
168
183
  return NextResponse.json({ error: "Not a git repository" }, { status: 400 });
169
184
  }
170
185
 
171
186
  if (action === "stage") {
172
187
  if (file) {
173
- await execPromise(`git add ${JSON.stringify(file)}`, { cwd: targetRoot });
188
+ await git(["add", "--", file], targetRoot);
174
189
  } else {
175
- await execPromise("git add -A", { cwd: targetRoot });
190
+ await git(["add", "-A"], targetRoot);
176
191
  }
177
192
  return NextResponse.json({ success: true });
178
193
  }
179
194
 
180
195
  if (action === "unstage") {
181
196
  if (file) {
182
- await execPromise(`git reset HEAD -- ${JSON.stringify(file)}`, { cwd: targetRoot });
197
+ await git(["reset", "HEAD", "--", file], targetRoot);
183
198
  } else {
184
- await execPromise("git reset HEAD -- .", { cwd: targetRoot });
199
+ await git(["reset", "HEAD", "--", "."], targetRoot);
185
200
  }
186
201
  return NextResponse.json({ success: true });
187
202
  }
@@ -192,7 +207,7 @@ export async function POST(request: Request) {
192
207
  }
193
208
  const absolutePath = path.resolve(targetRoot, file).replace(/\\/g, "/");
194
209
  // Check status to see if it is untracked
195
- const { stdout: statusOut } = await execPromise("git status --porcelain -- " + JSON.stringify(file), { cwd: targetRoot });
210
+ const { stdout: statusOut } = await git(["status", "--porcelain", "--", file], targetRoot);
196
211
  const isUntracked = statusOut.startsWith("??");
197
212
 
198
213
  if (isUntracked) {
@@ -200,7 +215,7 @@ export async function POST(request: Request) {
200
215
  fs.unlinkSync(absolutePath);
201
216
  }
202
217
  } else {
203
- await execPromise(`git checkout -- ${JSON.stringify(file)}`, { cwd: targetRoot });
218
+ await git(["checkout", "--", file], targetRoot);
204
219
  }
205
220
  return NextResponse.json({ success: true });
206
221
  }
@@ -209,7 +224,7 @@ export async function POST(request: Request) {
209
224
  if (!message || !message.trim()) {
210
225
  return NextResponse.json({ error: "Commit message cannot be empty" }, { status: 400 });
211
226
  }
212
- const { stdout } = await execPromise(`git commit -m ${JSON.stringify(message.trim())}`, { cwd: targetRoot });
227
+ const { stdout } = await git(["commit", "-m", message.trim()], targetRoot);
213
228
  return NextResponse.json({ success: true, output: stdout });
214
229
  }
215
230
 
@@ -231,40 +246,40 @@ export async function POST(request: Request) {
231
246
  }
232
247
 
233
248
  if (action === "pull") {
234
- const { stdout, stderr } = await execPromise("git pull", { cwd: targetRoot });
249
+ const { stdout, stderr } = await git(["pull"], targetRoot);
235
250
  return NextResponse.json({ success: true, output: stdout + stderr });
236
251
  }
237
252
 
238
253
  if (action === "push") {
239
- const { stdout, stderr } = await execPromise("git push", { cwd: targetRoot });
254
+ const { stdout, stderr } = await git(["push"], targetRoot);
240
255
  return NextResponse.json({ success: true, output: stdout + stderr });
241
256
  }
242
257
 
243
258
  if (action === "checkout") {
244
259
  const { branchName } = body;
245
- if (!branchName) {
246
- return NextResponse.json({ error: "Missing branchName parameter" }, { status: 400 });
260
+ if (!isValidBranchName(branchName)) {
261
+ return NextResponse.json({ error: "Missing or invalid branchName parameter" }, { status: 400 });
247
262
  }
248
- const { stdout, stderr } = await execPromise(`git checkout ${JSON.stringify(branchName)}`, { cwd: targetRoot });
263
+ const { stdout, stderr } = await git(["checkout", branchName], targetRoot);
249
264
  return NextResponse.json({ success: true, output: stdout + stderr });
250
265
  }
251
266
 
252
267
  if (action === "create_branch") {
253
268
  const { branchName } = body;
254
- if (!branchName) {
255
- return NextResponse.json({ error: "Missing branchName parameter" }, { status: 400 });
269
+ if (!isValidBranchName(branchName)) {
270
+ return NextResponse.json({ error: "Missing or invalid branchName parameter" }, { status: 400 });
256
271
  }
257
- const { stdout, stderr } = await execPromise(`git checkout -b ${JSON.stringify(branchName)}`, { cwd: targetRoot });
272
+ const { stdout, stderr } = await git(["checkout", "-b", branchName], targetRoot);
258
273
  return NextResponse.json({ success: true, output: stdout + stderr });
259
274
  }
260
275
 
261
276
  if (action === "delete_branch") {
262
277
  const { branchName, force } = body;
263
- if (!branchName) {
264
- return NextResponse.json({ error: "Missing branchName parameter" }, { status: 400 });
278
+ if (!isValidBranchName(branchName)) {
279
+ return NextResponse.json({ error: "Missing or invalid branchName parameter" }, { status: 400 });
265
280
  }
266
281
  const flag = force ? "-D" : "-d";
267
- const { stdout, stderr } = await execPromise(`git branch ${flag} ${JSON.stringify(branchName)}`, { cwd: targetRoot });
282
+ const { stdout, stderr } = await git(["branch", flag, branchName], targetRoot);
268
283
  return NextResponse.json({ success: true, output: stdout + stderr });
269
284
  }
270
285
 
@@ -1,10 +1,24 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { SessionManager } from "@/lib/agents/SessionManager";
3
3
  import { promisify } from "util";
4
- import { exec } from "child_process";
4
+ import { execFile } from "child_process";
5
5
  import os from "os";
6
6
 
7
- const execPromise = promisify(exec);
7
+ const execFilePromise = promisify(execFile);
8
+
9
+ // Real per-process stats for the PTY. Returns null when unavailable (pidusage
10
+ // not installed, or no live pid) so callers report honestly instead of faking it.
11
+ async function getProcessStats(pid: number | undefined): Promise<{ cpu: number; memory: number } | null> {
12
+ if (!pid) return null;
13
+ try {
14
+ /* eslint-disable @typescript-eslint/no-require-imports */
15
+ const pidusage = eval("require")("pidusage");
16
+ const stat = await pidusage(pid);
17
+ return { cpu: stat.cpu, memory: stat.memory };
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
8
22
 
9
23
  export async function GET(request: Request) {
10
24
  try {
@@ -21,42 +35,45 @@ export async function GET(request: Request) {
21
35
  return NextResponse.json({ error: "Session not found" }, { status: 404 });
22
36
  }
23
37
 
24
- // Default response metrics
38
+ // Default response metrics. `available` reports whether these are real
39
+ // measurements (vs. a placeholder when telemetry can't be collected).
25
40
  let cpu = "0.0%";
26
41
  let memory = "0.0MiB";
27
42
  let memoryPercent = "0.0%";
43
+ let available = false;
28
44
 
29
45
  if (session.status === "running") {
30
46
  const isDocker = session.type === "claude-docker" || session.type === "aider-docker";
31
47
  if (isDocker) {
32
48
  try {
33
- // Query docker stats
49
+ // Real container stats from the Docker daemon.
34
50
  const containerName = `neural-loom-session-${id}`;
35
- const { stdout } = await execPromise(`docker stats ${containerName} --no-stream --format "{{.CPUPerc}},{{.MemUsage}},{{.MemPerc}}"`);
36
- const parts = stdout.trim().split(",");
51
+ const { stdout } = await execFilePromise("docker", [
52
+ "stats",
53
+ containerName,
54
+ "--no-stream",
55
+ "--format",
56
+ "{{.CPUPerc}},{{.MemUsage}},{{.MemPerc}}",
57
+ ]);
58
+ const parts = stdout.toString().trim().split(",");
37
59
  if (parts.length >= 3) {
38
60
  cpu = parts[0].trim();
39
- memory = parts[1].trim().split(" / ")[0]; // get the used part, e.g. "142.2MiB"
61
+ memory = parts[1].trim().split(" / ")[0]; // used part, e.g. "142.2MiB"
40
62
  memoryPercent = parts[2].trim();
63
+ available = true;
41
64
  }
42
65
  } catch {
43
- // Docker stats not available/running, fallback to simulated telemetry
44
- const simulatedCpu = (Math.random() * 3 + 1).toFixed(1) + "%";
45
- const simulatedMem = (130 + Math.random() * 20).toFixed(1) + "MiB";
46
- const simulatedMemPerc = (1.5 + Math.random() * 0.5).toFixed(2) + "%";
47
- cpu = simulatedCpu;
48
- memory = simulatedMem;
49
- memoryPercent = simulatedMemPerc;
66
+ // Docker daemon/container not reachable leave defaults, available=false.
50
67
  }
51
68
  } else {
52
- // Local runner telemetry fallback
53
- const simulatedCpu = (Math.random() * 5 + 2).toFixed(1) + "%";
54
- const memBytes = process.memoryUsage().rss;
55
- const simulatedMem = (memBytes / (1024 * 1024)).toFixed(1) + "MiB";
56
- const simulatedMemPerc = ((memBytes / os.totalmem()) * 100).toFixed(2) + "%";
57
- cpu = simulatedCpu;
58
- memory = simulatedMem;
59
- memoryPercent = simulatedMemPerc;
69
+ // Real per-process stats for the local/SSH PTY process.
70
+ const stats = await getProcessStats(session.getPid());
71
+ if (stats) {
72
+ cpu = stats.cpu.toFixed(1) + "%";
73
+ memory = (stats.memory / (1024 * 1024)).toFixed(1) + "MiB";
74
+ memoryPercent = ((stats.memory / os.totalmem()) * 100).toFixed(2) + "%";
75
+ available = true;
76
+ }
60
77
  }
61
78
  }
62
79
 
@@ -68,6 +85,7 @@ export async function GET(request: Request) {
68
85
  cpu,
69
86
  memory,
70
87
  memoryPercent,
88
+ available,
71
89
  });
72
90
  } catch (error) {
73
91
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
@@ -1,19 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { ConnectionManager } from "@/lib/sql/ConnectionManager";
3
-
4
- const isMutation = (sql: string): boolean => {
5
- const clean = sql.trim().toLowerCase();
6
- return (
7
- clean.startsWith("insert") ||
8
- clean.startsWith("update") ||
9
- clean.startsWith("delete") ||
10
- clean.startsWith("drop") ||
11
- clean.startsWith("alter") ||
12
- clean.startsWith("create") ||
13
- clean.startsWith("truncate") ||
14
- clean.startsWith("replace")
15
- );
16
- };
3
+ import { isReadOnlyViolation } from "@/lib/sql/sqlGuard";
17
4
 
18
5
  export async function POST(request: Request) {
19
6
  try {
@@ -33,7 +20,7 @@ export async function POST(request: Request) {
33
20
 
34
21
  const { adapter, profile } = active;
35
22
 
36
- if (profile.readOnly && isMutation(sql)) {
23
+ if (profile.readOnly && isReadOnlyViolation(sql)) {
37
24
  return NextResponse.json({
38
25
  error: "This connection profile is configured as Read-Only. Modifications are disabled."
39
26
  }, { status: 403 });
@@ -1,10 +1,11 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
+ import { getConfigDir } from "@/lib/config";
4
5
 
5
6
  export async function GET() {
6
7
  try {
7
- const historyFile = path.join(process.cwd(), ".ai", "neural-loom", "token-history.json");
8
+ const historyFile = path.join(getConfigDir(), "token-history.json");
8
9
  if (!fs.existsSync(historyFile)) {
9
10
  return NextResponse.json({ success: true, history: [] });
10
11
  }
@@ -322,12 +322,8 @@ export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
322
322
 
323
323
  function copyTextToClipboard(text: string): boolean {
324
324
  if (typeof window === "undefined") return false;
325
- if (navigator.clipboard && navigator.clipboard.writeText) {
326
- navigator.clipboard.writeText(text).catch((err) => {
327
- console.error("Failed to copy using clipboard API:", err);
328
- });
329
- return true;
330
- }
325
+
326
+ let successful = false;
331
327
  try {
332
328
  const textArea = document.createElement("textarea");
333
329
  textArea.value = text;
@@ -338,13 +334,24 @@ function copyTextToClipboard(text: string): boolean {
338
334
  document.body.appendChild(textArea);
339
335
  textArea.focus();
340
336
  textArea.select();
341
- const successful = document.execCommand("copy");
337
+ successful = document.execCommand("copy");
342
338
  document.body.removeChild(textArea);
343
- return successful;
344
339
  } catch (err) {
345
- console.error("Fallback copy failed:", err);
346
- return false;
340
+ console.warn("Fallback execCommand copy failed:", err);
341
+ }
342
+
343
+ if (successful) {
344
+ return true;
345
+ }
346
+
347
+ if (navigator.clipboard && navigator.clipboard.writeText) {
348
+ navigator.clipboard.writeText(text).catch((err) => {
349
+ console.error("Failed to copy using clipboard API:", err);
350
+ });
351
+ return true;
347
352
  }
353
+
354
+ return false;
348
355
  }
349
356
 
350
357
  function CodeBlock({ code, language }: { code: string; language: string }) {
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
 
4
- import { useEffect, useRef, useState } from "react";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import { Terminal } from "xterm";
6
6
  import { FitAddon } from "xterm-addon-fit";
7
7
  import { SearchAddon } from "@xterm/addon-search";
@@ -19,14 +19,22 @@ interface LogBufferItem {
19
19
  message?: string;
20
20
  }
21
21
 
22
- function copyTextToClipboard(text: string): boolean {
23
- if (typeof window === "undefined") return false;
24
- if (navigator.clipboard && navigator.clipboard.writeText) {
25
- navigator.clipboard.writeText(text).catch((err) => {
26
- console.error("Failed to copy using clipboard API:", err);
27
- });
28
- return true;
22
+ async function copyTextToClipboard(text: string): Promise<boolean> {
23
+ if (typeof window === "undefined" || !text) return false;
24
+
25
+ // Preferred path: async Clipboard API. Reliable in a secure context, which
26
+ // includes localhost (NeuralLoom's default bind).
27
+ if (window.isSecureContext && navigator.clipboard?.writeText) {
28
+ try {
29
+ await navigator.clipboard.writeText(text);
30
+ return true;
31
+ } catch {
32
+ // Fall through to the execCommand fallback (e.g. permission denied).
33
+ }
29
34
  }
35
+
36
+ // Fallback: hidden textarea + execCommand("copy") for non-secure contexts
37
+ // (e.g. accessed over a plain-HTTP LAN address).
30
38
  try {
31
39
  const textArea = document.createElement("textarea");
32
40
  textArea.value = text;
@@ -41,7 +49,7 @@ function copyTextToClipboard(text: string): boolean {
41
49
  document.body.removeChild(textArea);
42
50
  return successful;
43
51
  } catch (err) {
44
- console.error("Fallback copy failed:", err);
52
+ console.warn("Clipboard copy failed:", err);
45
53
  return false;
46
54
  }
47
55
  }
@@ -64,6 +72,31 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
64
72
  const [caseSensitive, setCaseSensitive] = useState(false);
65
73
  const searchAddonRef = useRef<SearchAddon | null>(null);
66
74
 
75
+ // Copy feedback ("Copied!" indicator)
76
+ const [copied, setCopied] = useState(false);
77
+ const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
+
79
+ // Copy the given text (or the current terminal selection) to the clipboard.
80
+ // Stable identity so the terminal effect can use it without re-initializing.
81
+ const copySelection = useCallback((text?: string): void => {
82
+ const term = xtermRef.current;
83
+ const selection = (text ?? term?.getSelection() ?? "").trim();
84
+ if (!selection) return;
85
+ copyTextToClipboard(selection).then((ok) => {
86
+ if (!ok) return;
87
+ setCopied(true);
88
+ if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
89
+ copiedTimerRef.current = setTimeout(() => setCopied(false), 1200);
90
+ });
91
+ }, []);
92
+
93
+ // Clear the copy-feedback timer on unmount
94
+ useEffect(() => {
95
+ return () => {
96
+ if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
97
+ };
98
+ }, []);
99
+
67
100
  // Focus search input when opened
68
101
  useEffect(() => {
69
102
  if (searchOpen && searchInputRef.current) {
@@ -182,20 +215,18 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
182
215
  term.focus();
183
216
 
184
217
  const container = terminalRef.current;
218
+ // Keep right-click from clearing an active selection so the context-menu
219
+ // copy can read it.
185
220
  const handleMouseDown = (e: MouseEvent) => {
186
- if (e.button === 2 && term.hasSelection()) {
187
- e.stopPropagation();
188
- }
221
+ if (e.button === 2 && term.hasSelection()) e.stopPropagation();
189
222
  };
190
223
  const handleMouseUp = (e: MouseEvent) => {
191
- if (e.button === 2 && term.hasSelection()) {
192
- e.stopPropagation();
193
- }
224
+ if (e.button === 2 && term.hasSelection()) e.stopPropagation();
194
225
  };
195
226
  const handleContextMenu = (e: MouseEvent) => {
196
227
  if (term.hasSelection()) {
197
228
  e.preventDefault();
198
- copyTextToClipboard(term.getSelection());
229
+ copySelection(term.getSelection());
199
230
  term.clearSelection();
200
231
  }
201
232
  };
@@ -205,6 +236,17 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
205
236
  container.addEventListener("contextmenu", handleContextMenu);
206
237
  }
207
238
 
239
+ // Copy-on-select: capture the selection the moment it is made. Claude Code's
240
+ // TUI redraws constantly, which clears the xterm selection — so we copy
241
+ // immediately (debounced) rather than waiting for a later Ctrl+C/right-click.
242
+ let selTimer: ReturnType<typeof setTimeout> | null = null;
243
+ const selDisposable = term.onSelectionChange(() => {
244
+ const sel = term.getSelection();
245
+ if (!sel || !sel.trim()) return;
246
+ if (selTimer) clearTimeout(selTimer);
247
+ selTimer = setTimeout(() => copySelection(sel), 150);
248
+ });
249
+
208
250
  // Attach search key handler and clipboard copy handler
209
251
  term.attachCustomKeyEventHandler((e) => {
210
252
  // Search: Ctrl+F
@@ -213,11 +255,20 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
213
255
  setSearchOpen(true);
214
256
  return false;
215
257
  }
216
-
217
- // Copy: Ctrl+C or Cmd+C (when selection is active)
218
- if ((e.ctrlKey || e.metaKey) && e.key === "c" && e.type === "keydown") {
258
+
259
+ // Explicit copy: Ctrl+Shift+C / Cmd+Shift+C (always, when a selection exists)
260
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "c" || e.key === "C") && e.type === "keydown") {
261
+ if (term.hasSelection()) {
262
+ copySelection(term.getSelection());
263
+ return false;
264
+ }
265
+ }
266
+
267
+ // Copy: Ctrl+C / Cmd+C only when a selection is active (otherwise let it
268
+ // pass through to the PTY as an interrupt).
269
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === "c" && e.type === "keydown") {
219
270
  if (term.hasSelection()) {
220
- copyTextToClipboard(term.getSelection());
271
+ copySelection(term.getSelection());
221
272
  return false; // Prevent sending interrupt to PTY
222
273
  }
223
274
  }
@@ -400,6 +451,8 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
400
451
  return () => {
401
452
  isDisposed = true;
402
453
  clearTimeout(fitTimeout);
454
+ if (selTimer) clearTimeout(selTimer);
455
+ selDisposable.dispose();
403
456
  resizeObserver.disconnect();
404
457
  ws.close();
405
458
  if (container) {
@@ -414,7 +467,7 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
414
467
  console.warn("Error disposing terminal:", e);
415
468
  }
416
469
  };
417
- }, [sessionId, wsPort]);
470
+ }, [sessionId, wsPort, copySelection]);
418
471
  const handlePaste = async (e: React.ClipboardEvent<HTMLDivElement>) => {
419
472
  const items = e.clipboardData?.items;
420
473
  if (!items) return;
@@ -516,6 +569,31 @@ export default function TerminalConsole({ sessionId, height = "400px", wsPort =
516
569
  )}
517
570
  </div>
518
571
  <div style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
572
+ {copied && (
573
+ <span style={{
574
+ color: "#50fa7b",
575
+ fontSize: "0.65rem",
576
+ fontWeight: 600,
577
+ }}>
578
+ ✓ Copied
579
+ </span>
580
+ )}
581
+ <button
582
+ onClick={() => copySelection()}
583
+ style={{
584
+ padding: "2px 6px",
585
+ borderRadius: "3px",
586
+ border: "1px solid var(--border)",
587
+ backgroundColor: "transparent",
588
+ color: "var(--muted)",
589
+ fontSize: "0.65rem",
590
+ fontWeight: 600,
591
+ cursor: "pointer",
592
+ }}
593
+ title="Copy selected terminal text (also auto-copies on selection)"
594
+ >
595
+ 📋 Copy
596
+ </button>
519
597
  <button
520
598
  onClick={togglePause}
521
599
  style={{