codepiper 0.1.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 (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,133 @@
1
+ import {
2
+ isBodyTooLarge,
3
+ isImageBodyTooLarge,
4
+ MAX_BODY_SIZE,
5
+ MAX_IMAGE_BODY_SIZE,
6
+ } from "../auth/authMiddleware";
7
+
8
+ const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
9
+ const IMAGE_UPLOAD_PATH = /^\/(?:api\/)?sessions\/[a-zA-Z0-9-]+\/upload-image$/;
10
+ const AUDIO_TRANSCRIBE_PATH = /^\/(?:api\/)?sessions\/[a-zA-Z0-9-]+\/terminal\/transcribe$/;
11
+
12
+ class BodyTooLargeError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "BodyTooLargeError";
16
+ }
17
+ }
18
+
19
+ function isImageUploadPath(pathname: string): boolean {
20
+ return IMAGE_UPLOAD_PATH.test(pathname);
21
+ }
22
+
23
+ function isAudioTranscribePath(pathname: string): boolean {
24
+ return AUDIO_TRANSCRIBE_PATH.test(pathname);
25
+ }
26
+
27
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
28
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
29
+ }
30
+
31
+ async function readBodyWithLimit(req: Request, maxBytes: number): Promise<Uint8Array | null> {
32
+ if (!req.body) {
33
+ return null;
34
+ }
35
+
36
+ const reader = req.body.getReader();
37
+ const chunks: Uint8Array[] = [];
38
+ let total = 0;
39
+
40
+ try {
41
+ while (true) {
42
+ const { done, value } = await reader.read();
43
+ if (done) {
44
+ break;
45
+ }
46
+ if (!value) {
47
+ continue;
48
+ }
49
+
50
+ total += value.byteLength;
51
+ if (total > maxBytes) {
52
+ try {
53
+ await reader.cancel();
54
+ } catch {
55
+ // best-effort cancellation
56
+ }
57
+ throw new BodyTooLargeError(`Request body exceeds ${maxBytes} bytes`);
58
+ }
59
+
60
+ chunks.push(value);
61
+ }
62
+ } finally {
63
+ reader.releaseLock();
64
+ }
65
+
66
+ const body = new Uint8Array(total);
67
+ let offset = 0;
68
+ for (const chunk of chunks) {
69
+ body.set(chunk, offset);
70
+ offset += chunk.byteLength;
71
+ }
72
+ return body;
73
+ }
74
+
75
+ /**
76
+ * Enforce request body limits:
77
+ * - image/audio upload routes: 10MB limit
78
+ * - all other write routes: 1MB limit, including chunked bodies without Content-Length.
79
+ *
80
+ * Returns either the original/rebuilt Request, or a 413 Response.
81
+ */
82
+ export async function enforceRequestBodyLimit(
83
+ req: Request,
84
+ pathname: string
85
+ ): Promise<Request | Response> {
86
+ if (!BODY_METHODS.has(req.method.toUpperCase())) {
87
+ return req;
88
+ }
89
+
90
+ if (isImageUploadPath(pathname) || isAudioTranscribePath(pathname)) {
91
+ if (isImageBodyTooLarge(req)) {
92
+ return Response.json({ error: "Upload too large (max 10MB)" }, { status: 413 });
93
+ }
94
+ if (!req.headers.has("content-length")) {
95
+ try {
96
+ const body = await readBodyWithLimit(req, MAX_IMAGE_BODY_SIZE);
97
+ if (!body) {
98
+ return req;
99
+ }
100
+ return new Request(req, { body: toArrayBuffer(body) });
101
+ } catch (error) {
102
+ if (error instanceof BodyTooLargeError) {
103
+ return Response.json({ error: "Upload too large (max 10MB)" }, { status: 413 });
104
+ }
105
+ throw error;
106
+ }
107
+ }
108
+ return req;
109
+ }
110
+
111
+ if (isBodyTooLarge(req)) {
112
+ return Response.json({ error: "Request too large" }, { status: 413 });
113
+ }
114
+
115
+ // If Content-Length is present and already validated, no additional read is needed.
116
+ if (req.headers.has("content-length")) {
117
+ return req;
118
+ }
119
+
120
+ // No Content-Length (e.g. chunked): stream and enforce the same limit.
121
+ try {
122
+ const body = await readBodyWithLimit(req, MAX_BODY_SIZE);
123
+ if (!body) {
124
+ return req;
125
+ }
126
+ return new Request(req, { body: toArrayBuffer(body) });
127
+ } catch (error) {
128
+ if (error instanceof BodyTooLargeError) {
129
+ return Response.json({ error: "Request too large" }, { status: 413 });
130
+ }
131
+ throw error;
132
+ }
133
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Environment Variable Set API route handlers
3
+ */
4
+
5
+ import type { RouteContext } from "./routes";
6
+ import { hasAnyDefinedField, jsonError, parseJsonBody } from "./routeUtils";
7
+ import { getErrorMessage, isSqliteUniqueConstraintError } from "./sqliteErrors";
8
+
9
+ /**
10
+ * GET /env-sets - List all env sets (with masked values)
11
+ */
12
+ export async function handleListEnvSets(_req: Request, ctx: RouteContext): Promise<Response> {
13
+ const envSets = ctx.db.listEnvSets();
14
+ return Response.json({ envSets });
15
+ }
16
+
17
+ /**
18
+ * POST /env-sets - Create a new env set
19
+ */
20
+ export async function handleCreateEnvSet(req: Request, ctx: RouteContext): Promise<Response> {
21
+ const parsed = await parseJsonBody(req);
22
+ if (!parsed.ok) {
23
+ return parsed.response;
24
+ }
25
+ const body = parsed.body as any;
26
+
27
+ if (!body.name || typeof body.name !== "string") {
28
+ return jsonError(400, "Missing required field: name");
29
+ }
30
+ if (body.name.trim().length === 0) {
31
+ return jsonError(400, "Field 'name' must not be empty");
32
+ }
33
+ if (body.name.length > 100) {
34
+ return jsonError(400, "Name too long (max 100 chars)");
35
+ }
36
+ if (
37
+ body.description !== undefined &&
38
+ body.description !== null &&
39
+ typeof body.description !== "string"
40
+ ) {
41
+ return jsonError(400, "Field 'description' must be a string or null");
42
+ }
43
+ if (!body.vars || typeof body.vars !== "object" || Array.isArray(body.vars)) {
44
+ return jsonError(400, "Missing required field: vars (must be an object)");
45
+ }
46
+
47
+ // Validate all keys and values are strings
48
+ for (const [key, value] of Object.entries(body.vars)) {
49
+ if (typeof key !== "string" || typeof value !== "string") {
50
+ return jsonError(400, `All env var keys and values must be strings. Invalid: ${key}`);
51
+ }
52
+ }
53
+
54
+ const id = crypto.randomUUID();
55
+
56
+ try {
57
+ ctx.db.createEnvSet({
58
+ id,
59
+ name: body.name,
60
+ description: body.description,
61
+ vars: body.vars,
62
+ });
63
+ const envSet = ctx.db.getEnvSet(id);
64
+ return Response.json({ envSet }, { status: 201 });
65
+ } catch (error) {
66
+ const msg = getErrorMessage(error);
67
+ if (isSqliteUniqueConstraintError(error)) {
68
+ return jsonError(409, `Env set name already exists: ${body.name}`);
69
+ }
70
+ return jsonError(500, msg);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * GET /env-sets/:id - Get a specific env set (with masked values)
76
+ */
77
+ export async function handleGetEnvSet(
78
+ _req: Request,
79
+ ctx: RouteContext,
80
+ envSetId: string
81
+ ): Promise<Response> {
82
+ const envSet = ctx.db.getEnvSet(envSetId);
83
+ if (!envSet) {
84
+ return jsonError(404, `Env set not found: ${envSetId}`);
85
+ }
86
+ return Response.json({ envSet });
87
+ }
88
+
89
+ /**
90
+ * PUT /env-sets/:id - Update an env set
91
+ */
92
+ export async function handleUpdateEnvSet(
93
+ req: Request,
94
+ ctx: RouteContext,
95
+ envSetId: string
96
+ ): Promise<Response> {
97
+ const parsed = await parseJsonBody(req);
98
+ if (!parsed.ok) {
99
+ return parsed.response;
100
+ }
101
+ const body = parsed.body as any;
102
+
103
+ const existing = ctx.db.getEnvSet(envSetId);
104
+ if (!existing) {
105
+ return jsonError(404, `Env set not found: ${envSetId}`);
106
+ }
107
+
108
+ if (body.name !== undefined && typeof body.name !== "string") {
109
+ return jsonError(400, "name must be a string");
110
+ }
111
+ if (body.name !== undefined && body.name.trim().length === 0) {
112
+ return jsonError(400, "name must not be empty");
113
+ }
114
+ if (body.name !== undefined && body.name.length > 100) {
115
+ return jsonError(400, "Name too long (max 100 chars)");
116
+ }
117
+ if (
118
+ body.description !== undefined &&
119
+ body.description !== null &&
120
+ typeof body.description !== "string"
121
+ ) {
122
+ return jsonError(400, "description must be a string or null");
123
+ }
124
+ if (body.vars !== undefined) {
125
+ if (typeof body.vars !== "object" || Array.isArray(body.vars)) {
126
+ return jsonError(400, "vars must be an object");
127
+ }
128
+ for (const [key, value] of Object.entries(body.vars)) {
129
+ if (typeof key !== "string" || typeof value !== "string") {
130
+ return jsonError(400, `All env var keys and values must be strings. Invalid: ${key}`);
131
+ }
132
+ }
133
+ }
134
+ if (!hasAnyDefinedField(body, ["name", "description", "vars"])) {
135
+ return jsonError(422, "At least one field must be provided: name, description, or vars");
136
+ }
137
+
138
+ try {
139
+ ctx.db.updateEnvSet(envSetId, {
140
+ name: body.name,
141
+ description: body.description,
142
+ vars: body.vars,
143
+ });
144
+ const envSet = ctx.db.getEnvSet(envSetId);
145
+ return Response.json({ envSet });
146
+ } catch (error) {
147
+ const msg = getErrorMessage(error);
148
+ if (isSqliteUniqueConstraintError(error)) {
149
+ return jsonError(409, `Env set name already exists: ${body.name}`);
150
+ }
151
+ return jsonError(500, msg);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * DELETE /env-sets/:id - Delete an env set
157
+ */
158
+ export async function handleDeleteEnvSet(
159
+ _req: Request,
160
+ ctx: RouteContext,
161
+ envSetId: string
162
+ ): Promise<Response> {
163
+ const existing = ctx.db.getEnvSet(envSetId);
164
+ if (!existing) {
165
+ return jsonError(404, `Env set not found: ${envSetId}`);
166
+ }
167
+
168
+ ctx.db.deleteEnvSet(envSetId);
169
+ return Response.json({ success: true });
170
+ }
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Git API route handlers
3
+ *
4
+ * Provides git status, log, diff, file content, and staging operations
5
+ * scoped to a session's working directory.
6
+ */
7
+
8
+ import { GitUtils, validateFilePath, validateGitRef } from "../git/gitUtils";
9
+ import type { RouteContext } from "./routes";
10
+
11
+ const IMAGE_MIME_TYPES: Record<string, string> = {
12
+ png: "image/png",
13
+ jpg: "image/jpeg",
14
+ jpeg: "image/jpeg",
15
+ gif: "image/gif",
16
+ webp: "image/webp",
17
+ ico: "image/x-icon",
18
+ bmp: "image/bmp",
19
+ avif: "image/avif",
20
+ // SVG intentionally excluded — serving as image/svg+xml enables XSS via inline scripts
21
+ };
22
+
23
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
24
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
25
+ }
26
+
27
+ async function getSessionCwd(
28
+ ctx: RouteContext,
29
+ sessionId: string
30
+ ): Promise<{ cwd: string } | Response> {
31
+ const session = ctx.sessionManager.getSession(sessionId) ?? ctx.db.getSession(sessionId);
32
+ if (!session) {
33
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
34
+ }
35
+ return { cwd: session.cwd };
36
+ }
37
+
38
+ async function requireGitRepo(cwd: string): Promise<Response | null> {
39
+ const isRepo = await GitUtils.isGitRepo(cwd);
40
+ if (!isRepo) {
41
+ return Response.json({ error: "Session directory is not a git repository" }, { status: 400 });
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Shared setup for git file endpoints: resolves session cwd, checks git repo,
48
+ * and extracts/validates ref + path query params.
49
+ */
50
+ async function resolveGitFileParams(
51
+ req: Request,
52
+ ctx: RouteContext,
53
+ sessionId: string
54
+ ): Promise<{ cwd: string; ref: string; filePath: string } | Response> {
55
+ const result = await getSessionCwd(ctx, sessionId);
56
+ if (result instanceof Response) return result;
57
+
58
+ const repoCheck = await requireGitRepo(result.cwd);
59
+ if (repoCheck) return repoCheck;
60
+
61
+ const url = new URL(req.url);
62
+ const ref = url.searchParams.get("ref");
63
+ const filePath = url.searchParams.get("path");
64
+
65
+ if (!(ref && filePath)) {
66
+ return Response.json({ error: "Missing required query params: ref and path" }, { status: 400 });
67
+ }
68
+
69
+ try {
70
+ validateGitRef(ref);
71
+ validateFilePath(filePath);
72
+ } catch (error) {
73
+ const msg = error instanceof Error ? error.message : "Invalid input";
74
+ return Response.json({ error: msg }, { status: 400 });
75
+ }
76
+
77
+ return { cwd: result.cwd, ref, filePath };
78
+ }
79
+
80
+ /**
81
+ * GET /sessions/:id/git/status
82
+ */
83
+ export async function handleGitStatus(
84
+ _req: Request,
85
+ ctx: RouteContext,
86
+ sessionId: string
87
+ ): Promise<Response> {
88
+ const result = await getSessionCwd(ctx, sessionId);
89
+ if (result instanceof Response) return result;
90
+
91
+ const repoCheck = await requireGitRepo(result.cwd);
92
+ if (repoCheck) return repoCheck;
93
+
94
+ try {
95
+ const [status, branch] = await Promise.all([
96
+ GitUtils.getStatus(result.cwd),
97
+ GitUtils.getCurrentBranch(result.cwd),
98
+ ]);
99
+ return Response.json({ status, branch });
100
+ } catch (error) {
101
+ const msg = error instanceof Error ? error.message : "Unknown error";
102
+ return Response.json({ error: msg }, { status: 500 });
103
+ }
104
+ }
105
+
106
+ /**
107
+ * GET /sessions/:id/git/branches
108
+ */
109
+ export async function handleGitBranches(
110
+ _req: Request,
111
+ ctx: RouteContext,
112
+ sessionId: string
113
+ ): Promise<Response> {
114
+ const result = await getSessionCwd(ctx, sessionId);
115
+ if (result instanceof Response) return result;
116
+
117
+ const repoCheck = await requireGitRepo(result.cwd);
118
+ if (repoCheck) return repoCheck;
119
+
120
+ try {
121
+ const [branches, currentBranch] = await Promise.all([
122
+ GitUtils.listBranches(result.cwd),
123
+ GitUtils.getCurrentBranch(result.cwd),
124
+ ]);
125
+ return Response.json({ branches, currentBranch });
126
+ } catch (error) {
127
+ const msg = error instanceof Error ? error.message : "Unknown error";
128
+ return Response.json({ error: msg }, { status: 500 });
129
+ }
130
+ }
131
+
132
+ /**
133
+ * GET /sessions/:id/git/log?limit=50
134
+ */
135
+ export async function handleGitLog(
136
+ req: Request,
137
+ ctx: RouteContext,
138
+ sessionId: string
139
+ ): Promise<Response> {
140
+ const result = await getSessionCwd(ctx, sessionId);
141
+ if (result instanceof Response) return result;
142
+
143
+ const repoCheck = await requireGitRepo(result.cwd);
144
+ if (repoCheck) return repoCheck;
145
+
146
+ const url = new URL(req.url);
147
+ const limit = Number.parseInt(url.searchParams.get("limit") || "50", 10);
148
+
149
+ try {
150
+ const log = await GitUtils.getLog(result.cwd, { limit });
151
+ return Response.json({ log });
152
+ } catch (error) {
153
+ const msg = error instanceof Error ? error.message : "Unknown error";
154
+ return Response.json({ error: msg }, { status: 500 });
155
+ }
156
+ }
157
+
158
+ /**
159
+ * GET /sessions/:id/git/diff?staged=true&commit=abc&commitA=x&commitB=y&path=file.ts
160
+ */
161
+ export async function handleGitDiff(
162
+ req: Request,
163
+ ctx: RouteContext,
164
+ sessionId: string
165
+ ): Promise<Response> {
166
+ const result = await getSessionCwd(ctx, sessionId);
167
+ if (result instanceof Response) return result;
168
+
169
+ const repoCheck = await requireGitRepo(result.cwd);
170
+ if (repoCheck) return repoCheck;
171
+
172
+ const url = new URL(req.url);
173
+ const staged = url.searchParams.get("staged") === "true";
174
+ const commit = url.searchParams.get("commit");
175
+ const commitA = url.searchParams.get("commitA");
176
+ const commitB = url.searchParams.get("commitB");
177
+ const filePath = url.searchParams.get("path") || undefined;
178
+
179
+ try {
180
+ // Validate refs and paths
181
+ if (commit) validateGitRef(commit);
182
+ if (commitA) validateGitRef(commitA);
183
+ if (commitB) validateGitRef(commitB);
184
+ if (filePath) validateFilePath(filePath);
185
+ } catch (error) {
186
+ const msg = error instanceof Error ? error.message : "Invalid input";
187
+ return Response.json({ error: msg }, { status: 400 });
188
+ }
189
+
190
+ try {
191
+ let diff: string;
192
+
193
+ if (commit) {
194
+ diff = await GitUtils.getCommitDiff(result.cwd, commit, filePath);
195
+ } else {
196
+ diff = await GitUtils.getDiff(result.cwd, {
197
+ staged,
198
+ commitA: commitA || undefined,
199
+ commitB: commitB || undefined,
200
+ path: filePath,
201
+ });
202
+ }
203
+
204
+ return Response.json({ diff });
205
+ } catch (error) {
206
+ const msg = error instanceof Error ? error.message : "Unknown error";
207
+ return Response.json({ error: msg }, { status: 500 });
208
+ }
209
+ }
210
+
211
+ /**
212
+ * GET /sessions/:id/git/file?ref=HEAD&path=src/foo.ts
213
+ * Use ref=WORKING_TREE to read the file from the on-disk working tree.
214
+ */
215
+ export async function handleGitFile(
216
+ req: Request,
217
+ ctx: RouteContext,
218
+ sessionId: string
219
+ ): Promise<Response> {
220
+ const params = await resolveGitFileParams(req, ctx, sessionId);
221
+ if (params instanceof Response) return params;
222
+
223
+ try {
224
+ const content =
225
+ params.ref === "WORKING_TREE"
226
+ ? await GitUtils.getFileFromWorkingTree(params.cwd, params.filePath)
227
+ : await GitUtils.getFileAtRef(params.cwd, params.ref, params.filePath);
228
+ return Response.json({ content });
229
+ } catch (error) {
230
+ const msg = error instanceof Error ? error.message : "Unknown error";
231
+ return Response.json({ error: msg }, { status: 500 });
232
+ }
233
+ }
234
+
235
+ /**
236
+ * GET /sessions/:id/git/diff-stat?ref=abc123[&base=main]
237
+ */
238
+ export async function handleGitDiffStat(
239
+ req: Request,
240
+ ctx: RouteContext,
241
+ sessionId: string
242
+ ): Promise<Response> {
243
+ const result = await getSessionCwd(ctx, sessionId);
244
+ if (result instanceof Response) return result;
245
+
246
+ const repoCheck = await requireGitRepo(result.cwd);
247
+ if (repoCheck) return repoCheck;
248
+
249
+ const url = new URL(req.url);
250
+ const ref = url.searchParams.get("ref");
251
+ const base = url.searchParams.get("base");
252
+
253
+ // Branch comparison mode: base...ref
254
+ if (base) {
255
+ try {
256
+ validateGitRef(base);
257
+ if (ref) validateGitRef(ref);
258
+ } catch (error) {
259
+ const msg = error instanceof Error ? error.message : "Invalid input";
260
+ return Response.json({ error: msg }, { status: 400 });
261
+ }
262
+ try {
263
+ const files = await GitUtils.getDiffStatBetweenRefs(result.cwd, base, ref || "HEAD");
264
+ return Response.json({ files });
265
+ } catch (error) {
266
+ const msg = error instanceof Error ? error.message : "Unknown error";
267
+ return Response.json({ error: msg }, { status: 500 });
268
+ }
269
+ }
270
+
271
+ // Single-ref mode (commit vs parent)
272
+ if (!ref) {
273
+ return Response.json({ error: "Missing required query param: ref" }, { status: 400 });
274
+ }
275
+
276
+ try {
277
+ validateGitRef(ref);
278
+ } catch (error) {
279
+ const msg = error instanceof Error ? error.message : "Invalid input";
280
+ return Response.json({ error: msg }, { status: 400 });
281
+ }
282
+
283
+ try {
284
+ const files = await GitUtils.getDiffStat(result.cwd, ref);
285
+ return Response.json({ files });
286
+ } catch (error) {
287
+ const msg = error instanceof Error ? error.message : "Unknown error";
288
+ return Response.json({ error: msg }, { status: 500 });
289
+ }
290
+ }
291
+
292
+ /**
293
+ * GET /sessions/:id/git/file-raw?ref=HEAD&path=src/image.png
294
+ * Returns raw file bytes with appropriate Content-Type.
295
+ */
296
+ export async function handleGitFileRaw(
297
+ req: Request,
298
+ ctx: RouteContext,
299
+ sessionId: string
300
+ ): Promise<Response> {
301
+ const params = await resolveGitFileParams(req, ctx, sessionId);
302
+ if (params instanceof Response) return params;
303
+
304
+ try {
305
+ const data = await GitUtils.getFileAtRefRaw(params.cwd, params.ref, params.filePath);
306
+ const ext = params.filePath.split(".").pop()?.toLowerCase() || "";
307
+ const contentType = IMAGE_MIME_TYPES[ext] || "application/octet-stream";
308
+ return new Response(toArrayBuffer(data), {
309
+ headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
310
+ });
311
+ } catch (error) {
312
+ const msg = error instanceof Error ? error.message : "Unknown error";
313
+ return Response.json({ error: msg }, { status: 500 });
314
+ }
315
+ }
316
+
317
+ /**
318
+ * POST /sessions/:id/git/stage — body: { paths: string[] }
319
+ */
320
+ export async function handleGitStage(
321
+ req: Request,
322
+ ctx: RouteContext,
323
+ sessionId: string
324
+ ): Promise<Response> {
325
+ const result = await getSessionCwd(ctx, sessionId);
326
+ if (result instanceof Response) return result;
327
+
328
+ const repoCheck = await requireGitRepo(result.cwd);
329
+ if (repoCheck) return repoCheck;
330
+
331
+ let body: any;
332
+ try {
333
+ body = await req.json();
334
+ } catch {
335
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
336
+ }
337
+
338
+ if (!Array.isArray(body.paths) || body.paths.length === 0) {
339
+ return Response.json(
340
+ { error: "Missing required field: paths (non-empty array)" },
341
+ { status: 400 }
342
+ );
343
+ }
344
+
345
+ try {
346
+ for (const p of body.paths) {
347
+ if (typeof p !== "string") throw new Error("Each path must be a string");
348
+ validateFilePath(p);
349
+ }
350
+ } catch (error) {
351
+ const msg = error instanceof Error ? error.message : "Invalid input";
352
+ return Response.json({ error: msg }, { status: 400 });
353
+ }
354
+
355
+ try {
356
+ await GitUtils.stageFiles(result.cwd, body.paths);
357
+ return Response.json({ success: true });
358
+ } catch (error) {
359
+ const msg = error instanceof Error ? error.message : "Unknown error";
360
+ return Response.json({ error: msg }, { status: 500 });
361
+ }
362
+ }
363
+
364
+ /**
365
+ * POST /sessions/:id/git/unstage — body: { paths: string[] }
366
+ */
367
+ export async function handleGitUnstage(
368
+ req: Request,
369
+ ctx: RouteContext,
370
+ sessionId: string
371
+ ): Promise<Response> {
372
+ const result = await getSessionCwd(ctx, sessionId);
373
+ if (result instanceof Response) return result;
374
+
375
+ const repoCheck = await requireGitRepo(result.cwd);
376
+ if (repoCheck) return repoCheck;
377
+
378
+ let body: any;
379
+ try {
380
+ body = await req.json();
381
+ } catch {
382
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
383
+ }
384
+
385
+ if (!Array.isArray(body.paths) || body.paths.length === 0) {
386
+ return Response.json(
387
+ { error: "Missing required field: paths (non-empty array)" },
388
+ { status: 400 }
389
+ );
390
+ }
391
+
392
+ try {
393
+ for (const p of body.paths) {
394
+ if (typeof p !== "string") throw new Error("Each path must be a string");
395
+ validateFilePath(p);
396
+ }
397
+ } catch (error) {
398
+ const msg = error instanceof Error ? error.message : "Invalid input";
399
+ return Response.json({ error: msg }, { status: 400 });
400
+ }
401
+
402
+ try {
403
+ await GitUtils.unstageFiles(result.cwd, body.paths);
404
+ return Response.json({ success: true });
405
+ } catch (error) {
406
+ const msg = error instanceof Error ? error.message : "Unknown error";
407
+ return Response.json({ error: msg }, { status: 500 });
408
+ }
409
+ }