@chrysb/alphaclaw 0.3.5-beta.0 → 0.3.5-beta.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 (44) hide show
  1. package/bin/alphaclaw.js +65 -1
  2. package/lib/public/css/explorer.css +201 -6
  3. package/lib/public/js/app.js +45 -1
  4. package/lib/public/js/components/channels.js +1 -0
  5. package/lib/public/js/components/file-tree.js +56 -67
  6. package/lib/public/js/components/file-viewer/constants.js +6 -0
  7. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  8. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  9. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  10. package/lib/public/js/components/file-viewer/index.js +164 -0
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  12. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  13. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  14. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  15. package/lib/public/js/components/file-viewer/status-banners.js +59 -0
  16. package/lib/public/js/components/file-viewer/storage.js +58 -0
  17. package/lib/public/js/components/file-viewer/toolbar.js +77 -0
  18. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
  19. package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
  20. package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
  21. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
  24. package/lib/public/js/components/file-viewer/utils.js +11 -0
  25. package/lib/public/js/components/gateway.js +83 -30
  26. package/lib/public/js/components/icons.js +13 -0
  27. package/lib/public/js/components/sidebar-git-panel.js +72 -11
  28. package/lib/public/js/components/usage-tab.js +4 -1
  29. package/lib/public/js/components/watchdog-tab.js +6 -0
  30. package/lib/public/js/lib/api.js +16 -0
  31. package/lib/public/js/lib/browse-file-policies.js +34 -0
  32. package/lib/scripts/git +40 -0
  33. package/lib/scripts/git-askpass +6 -0
  34. package/lib/server/constants.js +8 -0
  35. package/lib/server/routes/browse/constants.js +51 -0
  36. package/lib/server/routes/browse/file-helpers.js +43 -0
  37. package/lib/server/routes/browse/git.js +131 -0
  38. package/lib/server/routes/{browse.js → browse/index.js} +290 -218
  39. package/lib/server/routes/browse/path-utils.js +53 -0
  40. package/lib/server/routes/browse/sqlite.js +140 -0
  41. package/lib/server/routes/proxy.js +11 -5
  42. package/lib/setup/core-prompts/TOOLS.md +0 -4
  43. package/package.json +1 -1
  44. package/lib/public/js/components/file-viewer.js +0 -1095
@@ -1,35 +1,30 @@
1
1
  const path = require("path");
2
- const { execFile } = require("child_process");
3
-
4
- const kDefaultTreeDepth = 10;
5
- const kIgnoredDirectoryNames = new Set([
6
- ".git",
7
- ".alphaclaw",
8
- "node_modules",
9
- ".cache",
10
- "dist",
11
- "build",
12
- ]);
13
- const kLockedBrowsePaths = new Set([
14
- "hooks/bootstrap/agents.md",
15
- "hooks/bootstrap/tools.md",
16
- ".alphaclaw/hourly-git-sync.sh",
17
- ".alphaclaw/.cli-device-auto-approved",
18
- ]);
19
-
20
- const matchesPolicyPath = (policyPathSet, normalizedPath) => {
21
- const safeNormalizedPath = String(normalizedPath || "").trim();
22
- if (!safeNormalizedPath) return false;
23
- for (const policyPath of policyPathSet) {
24
- if (
25
- safeNormalizedPath === policyPath ||
26
- safeNormalizedPath.endsWith(`/${policyPath}`)
27
- ) {
28
- return true;
29
- }
30
- }
31
- return false;
32
- };
2
+ const { kLockedBrowsePaths } = require("../../constants");
3
+ const {
4
+ kDefaultTreeDepth,
5
+ kIgnoredDirectoryNames,
6
+ kCommitHistoryLimit,
7
+ } = require("./constants");
8
+ const {
9
+ normalizePolicyPath,
10
+ resolveSafePath,
11
+ toRelativePath,
12
+ matchesPolicyPath,
13
+ } = require("./path-utils");
14
+ const {
15
+ isLikelyBinaryFile,
16
+ getImageMimeType,
17
+ getAudioMimeType,
18
+ isSqliteFilePath,
19
+ } = require("./file-helpers");
20
+ const { readSqliteSummary, readSqliteTableData } = require("./sqlite");
21
+ const {
22
+ runGitCommand,
23
+ runGitCommandWithExitCode,
24
+ parseGithubRepoSlug,
25
+ normalizeChangedPath,
26
+ parseBranchTracking,
27
+ } = require("./git");
33
28
 
34
29
  const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
35
30
  const kRootResolved = path.resolve(kRootDir);
@@ -40,55 +35,10 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
40
35
  fs.mkdirSync(kRootResolved, { recursive: true });
41
36
  }
42
37
 
43
- const normalizeRelativePath = (inputPath) => {
44
- const rawPath = String(inputPath || "").trim();
45
- if (!rawPath) return "";
46
- return rawPath.replace(/\\/g, "/").replace(/^\/+/, "");
47
- };
48
-
49
- const normalizePolicyPath = (inputPath) =>
50
- String(inputPath || "")
51
- .replace(/\\/g, "/")
52
- .replace(/^\.\/+/, "")
53
- .replace(/^\/+/, "")
54
- .trim()
55
- .toLowerCase();
56
-
57
- const resolveSafePath = (inputPath) => {
58
- const relativePath = normalizeRelativePath(inputPath);
59
- const absolutePath = path.resolve(kRootResolved, relativePath);
60
- const isInsideRoot =
61
- absolutePath === kRootResolved || absolutePath.startsWith(kRootWithSep);
62
- if (!isInsideRoot) {
63
- return { ok: false, error: `Path must stay within ${kRootDisplayName}` };
64
- }
65
- return { ok: true, relativePath, absolutePath };
66
- };
67
-
68
- const isLikelyBinaryFile = (targetPath) => {
69
- let fileHandle = null;
70
- try {
71
- fileHandle = fs.openSync(targetPath, "r");
72
- const sample = Buffer.alloc(512);
73
- const bytesRead = fs.readSync(fileHandle, sample, 0, sample.length, 0);
74
- for (let index = 0; index < bytesRead; index += 1) {
75
- if (sample[index] === 0) return true;
76
- }
77
- return false;
78
- } finally {
79
- if (fileHandle !== null) fs.closeSync(fileHandle);
80
- }
81
- };
82
-
83
- const toRelativePath = (absolutePath) => {
84
- const relative = path.relative(kRootResolved, absolutePath);
85
- return relative === "" ? "" : relative.split(path.sep).join("/");
86
- };
87
-
88
38
  const buildTreeNode = (absolutePath, depthRemaining) => {
89
39
  const stats = fs.statSync(absolutePath);
90
40
  const nodeName = path.basename(absolutePath);
91
- const nodePath = toRelativePath(absolutePath);
41
+ const nodePath = toRelativePath(absolutePath, kRootResolved);
92
42
 
93
43
  if (!stats.isDirectory()) {
94
44
  return { type: "file", name: nodeName, path: nodePath };
@@ -106,7 +56,9 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
106
56
  }
107
57
  return entry.isDirectory() || entry.isFile();
108
58
  })
109
- .map((entry) => buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1))
59
+ .map((entry) =>
60
+ buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1),
61
+ )
110
62
  .sort((leftNode, rightNode) => {
111
63
  if (leftNode.type !== rightNode.type) {
112
64
  return leftNode.type === "folder" ? -1 : 1;
@@ -117,87 +69,30 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
117
69
  return { type: "folder", name: nodeName, path: nodePath, children };
118
70
  };
119
71
 
120
- const runGitCommand = (args) =>
121
- new Promise((resolve) => {
122
- execFile(
123
- "git",
124
- args,
125
- { timeout: 10000, cwd: kRootResolved },
126
- (error, stdout, stderr) => {
127
- if (error) {
128
- return resolve({
129
- ok: false,
130
- error: String(stderr || stdout || error.message || "git command failed").trim(),
131
- });
132
- }
133
- return resolve({ ok: true, stdout: String(stdout || "") });
134
- },
135
- );
136
- });
137
-
138
- const runGitCommandWithExitCode = (args) =>
139
- new Promise((resolve) => {
140
- execFile(
141
- "git",
142
- args,
143
- { timeout: 10000, cwd: kRootResolved },
144
- (error, stdout, stderr) => {
145
- const safeStdout = String(stdout || "");
146
- const safeStderr = String(stderr || "");
147
- if (!error) {
148
- return resolve({
149
- ok: true,
150
- stdout: safeStdout,
151
- stderr: safeStderr,
152
- exitCode: 0,
153
- });
154
- }
155
- return resolve({
156
- ok: false,
157
- stdout: safeStdout,
158
- stderr: safeStderr,
159
- exitCode: Number.isInteger(error.code) ? error.code : 1,
160
- error: String(error.message || "git command failed"),
161
- });
162
- },
163
- );
164
- });
165
-
166
- const parseGithubRepoSlug = (value) => {
167
- const raw = String(value || "").trim();
168
- if (!raw) return "";
169
- return raw
170
- .replace(/^git@github\.com:/i, "")
171
- .replace(/^https:\/\/github\.com\//i, "")
172
- .replace(/\.git$/i, "")
173
- .trim();
174
- };
175
-
176
- const normalizeChangedPath = (rawPath) => {
177
- const value = String(rawPath || "").trim();
178
- if (!value) return "";
179
- if (value.includes(" -> ")) {
180
- const segments = value.split(" -> ");
181
- return String(segments[segments.length - 1] || "").trim();
182
- }
183
- return value;
184
- };
185
-
186
72
  app.get("/api/browse/tree", (req, res) => {
187
73
  const depthValue = Number.parseInt(String(req.query.depth || ""), 10);
188
- const depth = Number.isFinite(depthValue) && depthValue > 0 ? depthValue : kDefaultTreeDepth;
74
+ const depth =
75
+ Number.isFinite(depthValue) && depthValue > 0
76
+ ? depthValue
77
+ : kDefaultTreeDepth;
189
78
  try {
190
79
  const tree = buildTreeNode(kRootResolved, depth);
191
80
  return res.json({ ok: true, root: tree });
192
81
  } catch (error) {
193
- return res
194
- .status(500)
195
- .json({ ok: false, error: error.message || "Could not build file tree" });
82
+ return res.status(500).json({
83
+ ok: false,
84
+ error: error.message || "Could not build file tree",
85
+ });
196
86
  }
197
87
  });
198
88
 
199
89
  app.get("/api/browse/read", (req, res) => {
200
- const resolvedPath = resolveSafePath(req.query.path);
90
+ const resolvedPath = resolveSafePath(
91
+ req.query.path,
92
+ kRootResolved,
93
+ kRootWithSep,
94
+ kRootDisplayName,
95
+ );
201
96
  if (!resolvedPath.ok) {
202
97
  return res.status(400).json({ ok: false, error: resolvedPath.error });
203
98
  }
@@ -207,24 +102,70 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
207
102
  if (!stats.isFile()) {
208
103
  return res.status(400).json({ ok: false, error: "Path is not a file" });
209
104
  }
210
- if (isLikelyBinaryFile(resolvedPath.absolutePath)) {
211
- return res.status(400).json({ ok: false, error: "Binary files are not editable" });
105
+ if (isSqliteFilePath(resolvedPath.absolutePath)) {
106
+ const sqliteSummary = readSqliteSummary(resolvedPath.absolutePath);
107
+ return res.json({
108
+ ok: true,
109
+ path: resolvedPath.relativePath,
110
+ kind: "sqlite",
111
+ sqliteSummary,
112
+ content: "",
113
+ });
114
+ }
115
+ const audioMimeType = getAudioMimeType(resolvedPath.absolutePath);
116
+ if (audioMimeType) {
117
+ const audioBytes = fs.readFileSync(resolvedPath.absolutePath);
118
+ const audioDataUrl = `data:${audioMimeType};base64,${audioBytes.toString("base64")}`;
119
+ return res.json({
120
+ ok: true,
121
+ path: resolvedPath.relativePath,
122
+ kind: "audio",
123
+ mimeType: audioMimeType,
124
+ audioDataUrl,
125
+ content: "",
126
+ });
127
+ }
128
+ if (isLikelyBinaryFile(fs, resolvedPath.absolutePath)) {
129
+ const imageMimeType = getImageMimeType(resolvedPath.absolutePath);
130
+ if (!imageMimeType) {
131
+ return res
132
+ .status(400)
133
+ .json({ ok: false, error: "Binary files are not editable" });
134
+ }
135
+ const imageBytes = fs.readFileSync(resolvedPath.absolutePath);
136
+ const imageDataUrl = `data:${imageMimeType};base64,${imageBytes.toString("base64")}`;
137
+ return res.json({
138
+ ok: true,
139
+ path: resolvedPath.relativePath,
140
+ kind: "image",
141
+ mimeType: imageMimeType,
142
+ imageDataUrl,
143
+ content: "",
144
+ });
212
145
  }
213
146
  const content = fs.readFileSync(resolvedPath.absolutePath, "utf8");
214
147
  return res.json({
215
148
  ok: true,
216
149
  path: resolvedPath.relativePath,
150
+ kind: "text",
217
151
  content,
218
152
  });
219
153
  } catch (error) {
220
- return res.status(500).json({ ok: false, error: error.message || "Could not read file" });
154
+ return res
155
+ .status(500)
156
+ .json({ ok: false, error: error.message || "Could not read file" });
221
157
  }
222
158
  });
223
159
 
224
160
  app.get("/api/browse/git-summary", async (req, res) => {
225
161
  try {
226
- const envRepoSlug = parseGithubRepoSlug(process.env.GITHUB_WORKSPACE_REPO || "");
227
- const statusResult = await runGitCommand(["status", "--porcelain", "--branch"]);
162
+ const envRepoSlug = parseGithubRepoSlug(
163
+ process.env.GITHUB_WORKSPACE_REPO || "",
164
+ );
165
+ const statusResult = await runGitCommand(
166
+ ["status", "--porcelain", "--branch"],
167
+ kRootResolved,
168
+ );
228
169
  if (!statusResult.ok) {
229
170
  if (/not a git repository/i.test(statusResult.error || "")) {
230
171
  return res.json({
@@ -244,8 +185,12 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
244
185
  .map((line) => line.trimEnd())
245
186
  .filter(Boolean);
246
187
  const branchLine = statusLines.find((line) => line.startsWith("##")) || "";
247
- const branch = branchLine.replace(/^##\s*/, "").split("...")[0] || "unknown";
248
- const diffNumstatResult = await runGitCommand(["diff", "--numstat", "HEAD"]);
188
+ const branchTracking = parseBranchTracking(branchLine);
189
+ const branch = branchTracking.branch;
190
+ const diffNumstatResult = await runGitCommand(
191
+ ["diff", "--numstat", "HEAD"],
192
+ kRootResolved,
193
+ );
249
194
  const diffStatsByPath = new Map();
250
195
  if (diffNumstatResult.ok) {
251
196
  diffNumstatResult.stdout
@@ -253,7 +198,8 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
253
198
  .map((line) => line.trim())
254
199
  .filter(Boolean)
255
200
  .forEach((line) => {
256
- const [addedRaw = "", deletedRaw = "", rawPath = ""] = line.split("\t");
201
+ const [addedRaw = "", deletedRaw = "", rawPath = ""] =
202
+ line.split("\t");
257
203
  const normalizedPath = normalizeChangedPath(rawPath);
258
204
  if (!normalizedPath) return;
259
205
  const addedLines = Number.parseInt(addedRaw, 10);
@@ -290,26 +236,33 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
290
236
 
291
237
  let repoSlug = envRepoSlug;
292
238
  if (!repoSlug) {
293
- const remoteResult = await runGitCommand(["remote", "get-url", "origin"]);
239
+ const remoteResult = await runGitCommand(
240
+ ["remote", "get-url", "origin"],
241
+ kRootResolved,
242
+ );
294
243
  if (remoteResult.ok) {
295
244
  repoSlug = parseGithubRepoSlug(remoteResult.stdout || "");
296
245
  }
297
246
  }
298
247
  const repoUrl = repoSlug ? `https://github.com/${repoSlug}` : "";
299
248
 
300
- const logResult = await runGitCommand([
301
- "log",
302
- "--pretty=format:%H%x09%h%x09%s%x09%ct",
303
- "-n",
304
- "5",
305
- ]);
249
+ const logResult = await runGitCommand(
250
+ [
251
+ "log",
252
+ "--pretty=format:%H%x09%h%x09%s%x09%ct",
253
+ "-n",
254
+ String(kCommitHistoryLimit),
255
+ ],
256
+ kRootResolved,
257
+ );
306
258
  const commits = logResult.ok
307
259
  ? logResult.stdout
308
260
  .split("\n")
309
261
  .map((line) => line.trim())
310
262
  .filter(Boolean)
311
263
  .map((line) => {
312
- const [hash = "", shortHash = "", message = "", unixTs = "0"] = line.split("\t");
264
+ const [hash = "", shortHash = "", message = "", unixTs = "0"] =
265
+ line.split("\t");
313
266
  return {
314
267
  hash,
315
268
  shortHash,
@@ -327,6 +280,12 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
327
280
  repoSlug,
328
281
  repoUrl,
329
282
  branch,
283
+ upstreamBranch: branchTracking.upstreamBranch,
284
+ hasUpstream: branchTracking.hasUpstream,
285
+ upstreamGone: branchTracking.upstreamGone,
286
+ aheadCount: branchTracking.aheadCount,
287
+ behindCount: branchTracking.behindCount,
288
+ syncState: branchTracking.syncState,
330
289
  isDirty: changedFiles.length > 0,
331
290
  changedFilesCount: changedFiles.length,
332
291
  changedFiles: changedFiles.slice(0, 16),
@@ -340,8 +299,53 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
340
299
  }
341
300
  });
342
301
 
302
+ app.get("/api/browse/sqlite-table", (req, res) => {
303
+ const resolvedPath = resolveSafePath(
304
+ req.query.path,
305
+ kRootResolved,
306
+ kRootWithSep,
307
+ kRootDisplayName,
308
+ );
309
+ if (!resolvedPath.ok) {
310
+ return res.status(400).json({ ok: false, error: resolvedPath.error });
311
+ }
312
+ if (!isSqliteFilePath(resolvedPath.absolutePath)) {
313
+ return res.status(400).json({ ok: false, error: "Path is not a sqlite file" });
314
+ }
315
+ const tableName = String(req.query.table || "").trim();
316
+ const limit = req.query.limit;
317
+ const offset = req.query.offset;
318
+ const sqliteResult = readSqliteTableData(
319
+ resolvedPath.absolutePath,
320
+ tableName,
321
+ limit,
322
+ offset,
323
+ );
324
+ if (!sqliteResult.ok) {
325
+ return res.status(400).json({
326
+ ok: false,
327
+ error: sqliteResult.error || "Could not read sqlite table",
328
+ });
329
+ }
330
+ return res.json({
331
+ ok: true,
332
+ path: resolvedPath.relativePath,
333
+ table: sqliteResult.table,
334
+ columns: sqliteResult.columns,
335
+ rows: sqliteResult.rows,
336
+ limit: sqliteResult.limit,
337
+ offset: sqliteResult.offset,
338
+ totalRows: sqliteResult.totalRows,
339
+ });
340
+ });
341
+
343
342
  app.get("/api/browse/git-diff", async (req, res) => {
344
- const resolvedPath = resolveSafePath(req.query.path);
343
+ const resolvedPath = resolveSafePath(
344
+ req.query.path,
345
+ kRootResolved,
346
+ kRootWithSep,
347
+ kRootDisplayName,
348
+ );
345
349
  if (!resolvedPath.ok) {
346
350
  return res.status(400).json({ ok: false, error: resolvedPath.error });
347
351
  }
@@ -351,13 +355,14 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
351
355
  }
352
356
 
353
357
  try {
354
- const statusResult = await runGitCommandWithExitCode([
355
- "status",
356
- "--porcelain",
357
- "--",
358
- relativePath,
359
- ]);
360
- if (!statusResult.ok && /not a git repository/i.test(statusResult.stderr || "")) {
358
+ const statusResult = await runGitCommandWithExitCode(
359
+ ["status", "--porcelain", "--", relativePath],
360
+ kRootResolved,
361
+ );
362
+ if (
363
+ !statusResult.ok &&
364
+ /not a git repository/i.test(statusResult.stderr || "")
365
+ ) {
361
366
  return res.status(400).json({ ok: false, error: "No git repo at this root" });
362
367
  }
363
368
  const statusLines = statusResult.stdout
@@ -367,14 +372,14 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
367
372
  const isUntracked = statusLines.some((line) => line.startsWith("??"));
368
373
 
369
374
  const diffResult = isUntracked
370
- ? await runGitCommandWithExitCode([
371
- "diff",
372
- "--no-index",
373
- "--",
374
- "/dev/null",
375
- resolvedPath.absolutePath,
376
- ])
377
- : await runGitCommandWithExitCode(["diff", "HEAD", "--", relativePath]);
375
+ ? await runGitCommandWithExitCode(
376
+ ["diff", "--no-index", "--", "/dev/null", resolvedPath.absolutePath],
377
+ kRootResolved,
378
+ )
379
+ : await runGitCommandWithExitCode(
380
+ ["diff", "HEAD", "--", relativePath],
381
+ kRootResolved,
382
+ );
378
383
 
379
384
  const untrackedAllowedFailure =
380
385
  isUntracked && diffResult.exitCode === 1 && diffResult.stdout;
@@ -394,9 +399,10 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
394
399
  content,
395
400
  });
396
401
  } catch (error) {
397
- return res
398
- .status(500)
399
- .json({ ok: false, error: error.message || "Could not load file diff" });
402
+ return res.status(500).json({
403
+ ok: false,
404
+ error: error.message || "Could not load file diff",
405
+ });
400
406
  }
401
407
  });
402
408
 
@@ -404,57 +410,116 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
404
410
  try {
405
411
  const commitMessageRaw = String(req.body?.message || "").trim();
406
412
  const commitMessage = commitMessageRaw || "sync changes";
407
- const statusResult = await runGitCommand(["status", "--porcelain"]);
413
+ const statusResult = await runGitCommand(
414
+ ["status", "--porcelain", "--branch"],
415
+ kRootResolved,
416
+ );
408
417
  if (!statusResult.ok) {
409
418
  if (/not a git repository/i.test(statusResult.error || "")) {
410
- return res
411
- .status(400)
412
- .json({ ok: false, error: "No git repo at this root" });
419
+ return res.status(400).json({ ok: false, error: "No git repo at this root" });
413
420
  }
414
421
  return res.status(500).json({
415
422
  ok: false,
416
423
  error: statusResult.error || "Could not read git status",
417
424
  });
418
425
  }
419
- const hasChanges = statusResult.stdout
426
+ const statusLines = statusResult.stdout
420
427
  .split("\n")
421
- .map((line) => line.trim())
422
- .filter(Boolean).length > 0;
428
+ .map((line) => line.trimEnd())
429
+ .filter(Boolean);
430
+ const branchLine = statusLines.find((line) => line.startsWith("##")) || "";
431
+ const branchTracking = parseBranchTracking(branchLine);
432
+ const hasChanges =
433
+ statusLines
434
+ .filter((line) => !line.startsWith("##"))
435
+ .map((line) => line.trim())
436
+ .filter(Boolean).length > 0;
437
+ let committed = false;
438
+ let pushed = false;
439
+ let shortHash = "";
423
440
  if (!hasChanges) {
424
- return res.json({
425
- ok: true,
426
- committed: false,
427
- message: "No changes to sync",
428
- });
429
- }
430
- const addResult = await runGitCommand(["add", "-A"]);
431
- if (!addResult.ok) {
432
- return res.status(500).json({
433
- ok: false,
434
- error: addResult.error || "Could not stage changes",
435
- });
436
- }
437
- const commitResult = await runGitCommand(["commit", "-m", commitMessage]);
438
- if (!commitResult.ok) {
439
- if (/nothing to commit/i.test(commitResult.error || "")) {
441
+ const hasAheadCommits =
442
+ branchTracking.hasUpstream && branchTracking.aheadCount > 0;
443
+ if (!hasAheadCommits) {
440
444
  return res.json({
441
445
  ok: true,
442
446
  committed: false,
447
+ pushed: false,
443
448
  message: "No changes to sync",
444
449
  });
445
450
  }
446
- return res.status(500).json({
447
- ok: false,
448
- error: commitResult.error || "Could not commit changes",
449
- });
450
451
  }
451
- const shortHashResult = await runGitCommand(["rev-parse", "--short", "HEAD"]);
452
- const shortHash = shortHashResult.ok ? String(shortHashResult.stdout || "").trim() : "";
452
+ if (hasChanges) {
453
+ const addResult = await runGitCommand(["add", "-A"], kRootResolved);
454
+ if (!addResult.ok) {
455
+ return res.status(500).json({
456
+ ok: false,
457
+ error: addResult.error || "Could not stage changes",
458
+ });
459
+ }
460
+ const commitResult = await runGitCommand(
461
+ ["commit", "-m", commitMessage],
462
+ kRootResolved,
463
+ );
464
+ if (!commitResult.ok) {
465
+ if (/nothing to commit/i.test(commitResult.error || "")) {
466
+ return res.json({
467
+ ok: true,
468
+ committed: false,
469
+ pushed: false,
470
+ message: "No changes to sync",
471
+ });
472
+ }
473
+ return res.status(500).json({
474
+ ok: false,
475
+ error: commitResult.error || "Could not commit changes",
476
+ });
477
+ }
478
+ committed = true;
479
+ const shortHashResult = await runGitCommand(
480
+ ["rev-parse", "--short", "HEAD"],
481
+ kRootResolved,
482
+ );
483
+ shortHash = shortHashResult.ok
484
+ ? String(shortHashResult.stdout || "").trim()
485
+ : "";
486
+ }
487
+ const shouldPush = branchTracking.hasUpstream
488
+ ? branchTracking.aheadCount > 0 || committed
489
+ : committed;
490
+ if (shouldPush) {
491
+ const pushArgs = branchTracking.hasUpstream
492
+ ? ["push"]
493
+ : ["push", "-u", "origin", "HEAD"];
494
+ const pushResult = await runGitCommand(pushArgs, kRootResolved);
495
+ if (pushResult.ok) {
496
+ pushed = true;
497
+ } else {
498
+ return res.json({
499
+ ok: true,
500
+ committed,
501
+ pushed: false,
502
+ shortHash,
503
+ message: committed
504
+ ? `Committed ${shortHash || "changes"} locally; push failed`
505
+ : "Could not push commits",
506
+ pushError: pushResult.error || "Could not push commits",
507
+ });
508
+ }
509
+ }
510
+ const syncMessage = pushed
511
+ ? committed
512
+ ? `Committed and pushed ${shortHash || "changes"}`
513
+ : "Pushed local commits"
514
+ : committed
515
+ ? `Committed ${shortHash || "changes"}`
516
+ : "No changes to sync";
453
517
  return res.json({
454
518
  ok: true,
455
- committed: true,
519
+ committed,
520
+ pushed,
456
521
  shortHash,
457
- message: `Committed ${shortHash || "changes"}`,
522
+ message: syncMessage,
458
523
  });
459
524
  } catch (error) {
460
525
  return res.status(500).json({
@@ -466,7 +531,12 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
466
531
 
467
532
  app.put("/api/browse/write", async (req, res) => {
468
533
  const { path: targetPath, content } = req.body || {};
469
- const resolvedPath = resolveSafePath(targetPath);
534
+ const resolvedPath = resolveSafePath(
535
+ targetPath,
536
+ kRootResolved,
537
+ kRootWithSep,
538
+ kRootDisplayName,
539
+ );
470
540
  if (!resolvedPath.ok) {
471
541
  return res.status(400).json({ ok: false, error: resolvedPath.error });
472
542
  }
@@ -474,7 +544,7 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
474
544
  if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) {
475
545
  return res.status(403).json({
476
546
  ok: false,
477
- error: "This file is managed by Alpha Claw and cannot be edited.",
547
+ error: "This file is managed by AlphaClaw and cannot be edited.",
478
548
  });
479
549
  }
480
550
  if (typeof content !== "string") {
@@ -492,7 +562,9 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
492
562
  path: resolvedPath.relativePath,
493
563
  });
494
564
  } catch (error) {
495
- return res.status(500).json({ ok: false, error: error.message || "Could not save file" });
565
+ return res
566
+ .status(500)
567
+ .json({ ok: false, error: error.message || "Could not save file" });
496
568
  }
497
569
  });
498
570
  };