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,1004 @@
1
+ /**
2
+ * API route handlers
3
+ */
4
+
5
+ import type { EventBus } from "@codepiper/core";
6
+ import { SessionNotFoundError } from "@codepiper/core";
7
+ import type { Database, EventSource } from "../db/db";
8
+ import type { PushNotifier } from "../notifications/pushNotifier";
9
+ import {
10
+ getProviderDefinition,
11
+ isProviderId,
12
+ listProviderDefinitions,
13
+ listSupportedProviders,
14
+ } from "../providers/registry";
15
+ import type { AuditLogger } from "../sessions/auditLogger";
16
+ import type { PolicyEngine } from "../sessions/policyEngine";
17
+ import type { SessionManager } from "../sessions/sessionManager";
18
+ import { handleHookEvent } from "./hooks";
19
+ import { enforceInputPolicyPreflight } from "./inputPolicy";
20
+ import {
21
+ validateArgs,
22
+ validateCwd,
23
+ validateEnv,
24
+ validateImageUpload,
25
+ validateKeys,
26
+ validateModel,
27
+ validateText,
28
+ } from "./validation";
29
+
30
+ export interface RouteContext {
31
+ sessionManager: SessionManager;
32
+ db: Database;
33
+ eventBus: EventBus;
34
+ policyEngine: PolicyEngine;
35
+ auditLogger: AuditLogger;
36
+ authService?: import("../auth/authService").AuthService;
37
+ rateLimiter?: import("../auth/rateLimiter").RateLimiter;
38
+ /** Shared daemon secret for authenticating hook requests */
39
+ hookSecret?: string;
40
+ /** Optional callback to request daemon restart (used by settings endpoint) */
41
+ restartDaemon?: () => Promise<void> | void;
42
+ /** Optional push notifier status provider */
43
+ pushNotifier?: PushNotifier;
44
+ }
45
+
46
+ const SESSION_CUSTOM_NAME_MAX_LENGTH = 80;
47
+
48
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
49
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
50
+ }
51
+
52
+ function hasControlCharacters(value: string): boolean {
53
+ for (let index = 0; index < value.length; index++) {
54
+ const codePoint = value.charCodeAt(index);
55
+ if ((codePoint >= 0 && codePoint <= 31) || codePoint === 127) {
56
+ return true;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+
62
+ function upsertSessionCustomName(
63
+ metadata: Record<string, unknown> | undefined,
64
+ customName: string | null
65
+ ): Record<string, unknown> {
66
+ const current = isObjectRecord(metadata) ? metadata : {};
67
+ const next = { ...current };
68
+ const currentUi = isObjectRecord(current.ui) ? current.ui : {};
69
+ const nextUi = { ...currentUi };
70
+
71
+ if (customName) {
72
+ nextUi.customName = customName;
73
+ } else {
74
+ delete nextUi.customName;
75
+ }
76
+
77
+ if (Object.keys(nextUi).length > 0) {
78
+ next.ui = nextUi;
79
+ } else {
80
+ delete next.ui;
81
+ }
82
+
83
+ return Object.keys(next).length > 0 ? next : {};
84
+ }
85
+
86
+ function errorResponse(error: unknown, fallbackStatus = 500): Response {
87
+ if (error instanceof SessionNotFoundError) {
88
+ return Response.json({ error: error.message }, { status: 404 });
89
+ }
90
+ return Response.json(
91
+ { error: error instanceof Error ? error.message : "Unknown error" },
92
+ { status: fallbackStatus }
93
+ );
94
+ }
95
+
96
+ function listActiveDbSessions(ctx: RouteContext) {
97
+ return [
98
+ ...ctx.db.listSessions({ status: "RUNNING" }),
99
+ ...ctx.db.listSessions({ status: "STARTING" }),
100
+ ];
101
+ }
102
+
103
+ function listCodepiperTmuxSessionNames(): Set<string> | null {
104
+ try {
105
+ const tmuxResult = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], {
106
+ stdout: "pipe",
107
+ stderr: "ignore",
108
+ });
109
+ if (tmuxResult.exitCode !== 0) {
110
+ return null;
111
+ }
112
+
113
+ const names = (tmuxResult.stdout?.toString() ?? "")
114
+ .split("\n")
115
+ .map((name) => name.trim())
116
+ .filter((name) => name.startsWith("codepiper-"));
117
+ return new Set(names);
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ export async function handleHealth(_req: Request, ctx: RouteContext): Promise<Response> {
124
+ const activeDbSessions = listActiveDbSessions(ctx);
125
+ const activeInMemorySessionIds = new Set(
126
+ ctx.sessionManager.listSessions().map((session) => session.id)
127
+ );
128
+ const tmuxSessionNames = listCodepiperTmuxSessionNames();
129
+
130
+ let zombieSessionCount = 0;
131
+ for (const session of activeDbSessions) {
132
+ if (activeInMemorySessionIds.has(session.id)) {
133
+ continue;
134
+ }
135
+ if (tmuxSessionNames?.has(`codepiper-${session.id}`)) {
136
+ continue;
137
+ }
138
+ zombieSessionCount++;
139
+ }
140
+
141
+ return Response.json({
142
+ status: "ok",
143
+ zombieSessionCount,
144
+ });
145
+ }
146
+
147
+ export async function handleVersion(_req: Request, _ctx: RouteContext): Promise<Response> {
148
+ return Response.json({
149
+ version: "0.1.0",
150
+ bun: Bun.version,
151
+ });
152
+ }
153
+
154
+ export async function handleListProviders(_req: Request, _ctx: RouteContext): Promise<Response> {
155
+ return Response.json({
156
+ providers: listProviderDefinitions().map((provider) => ({
157
+ id: provider.id,
158
+ label: provider.label,
159
+ runtime: provider.runtime,
160
+ capabilities: provider.capabilities,
161
+ launchHints: provider.launchHints,
162
+ })),
163
+ });
164
+ }
165
+
166
+ export async function handleListSessions(_req: Request, ctx: RouteContext): Promise<Response> {
167
+ // Merge in-memory (active) sessions with DB (stopped/crashed) sessions
168
+ const activeSessions = ctx.sessionManager.listSessions();
169
+ const activeIds = new Set(activeSessions.map((s) => s.id));
170
+ const dbSessions = ctx.db.listSessions().filter((s) => !activeIds.has(s.id));
171
+
172
+ // Health check DB-only sessions that claim to be active
173
+ const adoptedSessions: any[] = [];
174
+ const remainingDbSessions: typeof dbSessions = [];
175
+
176
+ for (const session of dbSessions) {
177
+ if (session.status === "RUNNING" || session.status === "STARTING") {
178
+ const tmuxCheck = Bun.spawnSync(["tmux", "has-session", "-t", `codepiper-${session.id}`], {
179
+ stdout: "ignore",
180
+ stderr: "ignore",
181
+ });
182
+ if (tmuxCheck.exitCode !== 0) {
183
+ ctx.db.updateSession(session.id, { status: "STOPPED" });
184
+ session.status = "STOPPED";
185
+ remainingDbSessions.push(session);
186
+ } else {
187
+ // Tmux alive but not in memory — auto-adopt
188
+ try {
189
+ const adopted = await ctx.sessionManager.adoptSession(session.id);
190
+ adoptedSessions.push(adopted);
191
+ } catch {
192
+ remainingDbSessions.push(session);
193
+ }
194
+ }
195
+ } else {
196
+ remainingDbSessions.push(session);
197
+ }
198
+ }
199
+
200
+ return Response.json({
201
+ sessions: [...activeSessions, ...adoptedSessions, ...remainingDbSessions],
202
+ });
203
+ }
204
+
205
+ export async function handleCreateSession(req: Request, ctx: RouteContext): Promise<Response> {
206
+ let body: any;
207
+ try {
208
+ body = await req.json();
209
+ } catch {
210
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
211
+ }
212
+
213
+ // Validate required fields
214
+ if (!body.provider) {
215
+ return Response.json({ error: "Missing required field: provider" }, { status: 400 });
216
+ }
217
+
218
+ if (!body.cwd) {
219
+ return Response.json({ error: "Missing required field: cwd" }, { status: 400 });
220
+ }
221
+
222
+ // Validate provider
223
+ const validProviders = listSupportedProviders();
224
+ if (!(typeof body.provider === "string" && isProviderId(body.provider))) {
225
+ return Response.json(
226
+ {
227
+ error: `Invalid provider: ${body.provider}. Must be one of: ${validProviders.join(", ")}`,
228
+ },
229
+ { status: 400 }
230
+ );
231
+ }
232
+ const providerDefinition = getProviderDefinition(body.provider);
233
+
234
+ // Validate cwd (path length, format)
235
+ const cwdValidation = validateCwd(body.cwd);
236
+ if (!cwdValidation.valid) {
237
+ return Response.json({ error: cwdValidation.error }, { status: 400 });
238
+ }
239
+
240
+ // Validate optional env (if provided)
241
+ if (body.env !== undefined) {
242
+ const envValidation = validateEnv(body.env);
243
+ if (!envValidation.valid) {
244
+ return Response.json({ error: envValidation.error }, { status: 400 });
245
+ }
246
+ }
247
+
248
+ // Validate optional args (if provided)
249
+ if (body.args !== undefined) {
250
+ const argsValidation = validateArgs(body.args);
251
+ if (!argsValidation.valid) {
252
+ return Response.json({ error: argsValidation.error }, { status: 400 });
253
+ }
254
+ }
255
+
256
+ // Validate optional billingMode (if provided)
257
+ if (body.billingMode !== undefined) {
258
+ const validModes = ["subscription", "api"];
259
+ if (!validModes.includes(body.billingMode)) {
260
+ return Response.json(
261
+ {
262
+ error: `Invalid billingMode: ${body.billingMode}. Must be one of: ${validModes.join(", ")}`,
263
+ },
264
+ { status: 400 }
265
+ );
266
+ }
267
+ }
268
+
269
+ if (body.dangerousMode !== undefined && typeof body.dangerousMode !== "boolean") {
270
+ return Response.json({ error: "dangerousMode must be a boolean" }, { status: 400 });
271
+ }
272
+ if (
273
+ body.dangerousMode === true &&
274
+ providerDefinition.capabilities.supportsDangerousMode !== true
275
+ ) {
276
+ return Response.json(
277
+ { error: `dangerousMode is not supported for provider ${providerDefinition.id}` },
278
+ { status: 400 }
279
+ );
280
+ }
281
+
282
+ // Validate optional envSetIds (if provided)
283
+ if (body.envSetIds !== undefined) {
284
+ if (!Array.isArray(body.envSetIds)) {
285
+ return Response.json({ error: "envSetIds must be an array of strings" }, { status: 400 });
286
+ }
287
+ for (const id of body.envSetIds) {
288
+ if (typeof id !== "string") {
289
+ return Response.json({ error: "envSetIds must be an array of strings" }, { status: 400 });
290
+ }
291
+ const envSet = ctx.db.getEnvSet(id);
292
+ if (!envSet) {
293
+ return Response.json({ error: `Env set not found: ${id}` }, { status: 404 });
294
+ }
295
+ }
296
+ }
297
+
298
+ // Validate optional worktree config (if provided)
299
+ if (body.worktree !== undefined) {
300
+ if (typeof body.worktree !== "object" || body.worktree === null) {
301
+ return Response.json({ error: "worktree must be an object" }, { status: 400 });
302
+ }
303
+ if (!body.worktree.branch || typeof body.worktree.branch !== "string") {
304
+ return Response.json({ error: "worktree.branch is required" }, { status: 400 });
305
+ }
306
+ if (typeof body.worktree.createBranch !== "boolean") {
307
+ return Response.json({ error: "worktree.createBranch must be a boolean" }, { status: 400 });
308
+ }
309
+ if (body.worktree.startPoint !== undefined && typeof body.worktree.startPoint !== "string") {
310
+ return Response.json({ error: "worktree.startPoint must be a string" }, { status: 400 });
311
+ }
312
+ }
313
+
314
+ if (body.providerResume !== undefined) {
315
+ if (typeof body.providerResume !== "object" || body.providerResume === null) {
316
+ return Response.json({ error: "providerResume must be an object" }, { status: 400 });
317
+ }
318
+ if (typeof body.providerResume.providerSessionId !== "string") {
319
+ return Response.json(
320
+ { error: "providerResume.providerSessionId must be a string" },
321
+ { status: 400 }
322
+ );
323
+ }
324
+ if (body.providerResume.providerSessionId.trim().length === 0) {
325
+ return Response.json(
326
+ { error: "providerResume.providerSessionId must not be empty" },
327
+ { status: 400 }
328
+ );
329
+ }
330
+ if (
331
+ body.providerResume.mode !== undefined &&
332
+ body.providerResume.mode !== "resume" &&
333
+ body.providerResume.mode !== "fork"
334
+ ) {
335
+ return Response.json(
336
+ { error: 'providerResume.mode must be either "resume" or "fork"' },
337
+ { status: 400 }
338
+ );
339
+ }
340
+ }
341
+
342
+ try {
343
+ const session = await ctx.sessionManager.createSession({
344
+ provider: body.provider,
345
+ cwd: body.cwd,
346
+ env: body.env,
347
+ args: body.args,
348
+ billingMode: body.billingMode,
349
+ dangerousMode: body.dangerousMode,
350
+ envSetIds: body.envSetIds,
351
+ providerResume:
352
+ body.providerResume !== undefined
353
+ ? {
354
+ providerSessionId: body.providerResume.providerSessionId.trim(),
355
+ mode: body.providerResume.mode,
356
+ }
357
+ : undefined,
358
+ worktree: body.worktree,
359
+ });
360
+
361
+ return Response.json({ session }, { status: 201 });
362
+ } catch (error) {
363
+ return errorResponse(error);
364
+ }
365
+ }
366
+
367
+ export async function handleGetSession(
368
+ _req: Request,
369
+ ctx: RouteContext,
370
+ sessionId: string
371
+ ): Promise<Response> {
372
+ // Try in-memory first (active sessions), then fall back to DB (stopped/crashed)
373
+ const inMemory = ctx.sessionManager.getSession(sessionId);
374
+ if (inMemory) {
375
+ return Response.json({ session: inMemory });
376
+ }
377
+
378
+ const session = ctx.db.getSession(sessionId);
379
+ if (!session) {
380
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
381
+ }
382
+
383
+ // Health check: if DB says RUNNING/STARTING but not in memory
384
+ if (session.status === "RUNNING" || session.status === "STARTING") {
385
+ const tmuxCheck = Bun.spawnSync(["tmux", "has-session", "-t", `codepiper-${sessionId}`], {
386
+ stdout: "ignore",
387
+ stderr: "ignore",
388
+ });
389
+ if (tmuxCheck.exitCode !== 0) {
390
+ // Tmux is gone — mark STOPPED
391
+ ctx.db.updateSession(sessionId, { status: "STOPPED" });
392
+ session.status = "STOPPED";
393
+ } else {
394
+ // Tmux is alive but not in memory — auto-adopt
395
+ try {
396
+ const adopted = await ctx.sessionManager.adoptSession(sessionId);
397
+ return Response.json({ session: adopted });
398
+ } catch {
399
+ // Fall through to return DB data
400
+ }
401
+ }
402
+ }
403
+
404
+ return Response.json({ session });
405
+ }
406
+
407
+ export async function handleStopSession(
408
+ _req: Request,
409
+ ctx: RouteContext,
410
+ sessionId: string
411
+ ): Promise<Response> {
412
+ try {
413
+ await ctx.sessionManager.stopSession(sessionId);
414
+ return Response.json({ success: true });
415
+ } catch (error) {
416
+ // If session not in memory, try to transition orphaned DB session to STOPPED
417
+ if (error instanceof SessionNotFoundError) {
418
+ return transitionOrphanedSession(ctx, sessionId);
419
+ }
420
+ return errorResponse(error);
421
+ }
422
+ }
423
+
424
+ export async function handleKillSession(
425
+ _req: Request,
426
+ ctx: RouteContext,
427
+ sessionId: string
428
+ ): Promise<Response> {
429
+ try {
430
+ await ctx.sessionManager.killSession(sessionId);
431
+ return Response.json({ success: true });
432
+ } catch (error) {
433
+ // If session not in memory, try to transition orphaned DB session to STOPPED
434
+ if (error instanceof SessionNotFoundError) {
435
+ return transitionOrphanedSession(ctx, sessionId);
436
+ }
437
+ return errorResponse(error);
438
+ }
439
+ }
440
+
441
+ export async function handleResumeSession(
442
+ _req: Request,
443
+ ctx: RouteContext,
444
+ sessionId: string
445
+ ): Promise<Response> {
446
+ try {
447
+ const session = await ctx.sessionManager.resumeSession(sessionId);
448
+ return Response.json({ session });
449
+ } catch (error) {
450
+ return errorResponse(error, 400);
451
+ }
452
+ }
453
+
454
+ export async function handleRecoverSession(
455
+ _req: Request,
456
+ ctx: RouteContext,
457
+ sessionId: string
458
+ ): Promise<Response> {
459
+ // Already in memory — already active
460
+ const inMemory = ctx.sessionManager.getSession(sessionId);
461
+ if (inMemory) {
462
+ return Response.json({ session: inMemory });
463
+ }
464
+
465
+ // Try to adopt the orphaned session
466
+ try {
467
+ const session = await ctx.sessionManager.recoverSession(sessionId);
468
+ return Response.json({ session });
469
+ } catch (error) {
470
+ const msg = error instanceof Error ? error.message : "Failed to recover session";
471
+ return Response.json({ error: msg }, { status: 400 });
472
+ }
473
+ }
474
+
475
+ export async function handleUpdateSessionName(
476
+ req: Request,
477
+ ctx: RouteContext,
478
+ sessionId: string
479
+ ): Promise<Response> {
480
+ let body: any;
481
+ try {
482
+ body = await req.json();
483
+ } catch {
484
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
485
+ }
486
+
487
+ if (!(isObjectRecord(body) && "name" in body)) {
488
+ return Response.json({ error: "Missing required field: name" }, { status: 400 });
489
+ }
490
+
491
+ if (!(typeof body.name === "string" || body.name === null)) {
492
+ return Response.json({ error: "name must be a string or null" }, { status: 400 });
493
+ }
494
+
495
+ let normalizedName: string | null = null;
496
+ if (typeof body.name === "string") {
497
+ const trimmed = body.name.trim();
498
+ if (trimmed.length > 0) {
499
+ if (trimmed.length > SESSION_CUSTOM_NAME_MAX_LENGTH) {
500
+ return Response.json(
501
+ { error: `name must be ${SESSION_CUSTOM_NAME_MAX_LENGTH} characters or fewer` },
502
+ { status: 400 }
503
+ );
504
+ }
505
+ if (hasControlCharacters(trimmed)) {
506
+ return Response.json(
507
+ { error: "name must not contain control characters" },
508
+ { status: 400 }
509
+ );
510
+ }
511
+ normalizedName = trimmed;
512
+ }
513
+ }
514
+
515
+ const activeSession = ctx.sessionManager.getSession(sessionId);
516
+ if (activeSession) {
517
+ const session = ctx.sessionManager.setSessionCustomName(sessionId, normalizedName);
518
+ return Response.json({ session });
519
+ }
520
+
521
+ const dbSession = ctx.db.getSession(sessionId);
522
+ if (!dbSession) {
523
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
524
+ }
525
+
526
+ const nextMetadata = upsertSessionCustomName(dbSession.metadata, normalizedName);
527
+ ctx.db.updateSession(sessionId, { metadata: nextMetadata });
528
+
529
+ const updatedSession = ctx.db.getSession(sessionId);
530
+ return Response.json({ session: updatedSession ?? { ...dbSession, metadata: nextMetadata } });
531
+ }
532
+
533
+ /**
534
+ * Transition an orphaned session (exists in DB but not in memory) to STOPPED.
535
+ * Also attempts to clean up any lingering tmux session.
536
+ */
537
+ function transitionOrphanedSession(ctx: RouteContext, sessionId: string): Response {
538
+ const dbSession = ctx.db.getSession(sessionId);
539
+ if (!dbSession) {
540
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
541
+ }
542
+
543
+ // Already in terminal state — nothing to do
544
+ if (dbSession.status === "STOPPED" || dbSession.status === "CRASHED") {
545
+ return Response.json({ success: true });
546
+ }
547
+
548
+ // Update DB to STOPPED
549
+ ctx.db.updateSession(sessionId, { status: "STOPPED" });
550
+
551
+ // Best-effort: kill any orphaned tmux session that might still exist
552
+ try {
553
+ Bun.spawnSync(["tmux", "kill-session", "-t", `codepiper-${sessionId}`], {
554
+ stdout: "ignore",
555
+ stderr: "ignore",
556
+ });
557
+ } catch {
558
+ // tmux session may not exist — that's fine
559
+ }
560
+
561
+ return Response.json({ success: true });
562
+ }
563
+
564
+ export async function handleSendText(
565
+ req: Request,
566
+ ctx: RouteContext,
567
+ sessionId: string
568
+ ): Promise<Response> {
569
+ let body: any;
570
+ try {
571
+ body = await req.json();
572
+ } catch {
573
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
574
+ }
575
+
576
+ // Validate text field
577
+ if (typeof body.text !== "string") {
578
+ return Response.json({ error: "Missing required field: text" }, { status: 400 });
579
+ }
580
+
581
+ const textValidation = validateText(body.text);
582
+ if (!textValidation.valid) {
583
+ return Response.json({ error: textValidation.error }, { status: 400 });
584
+ }
585
+
586
+ try {
587
+ const policyCheck = await enforceInputPolicyPreflight(ctx, sessionId, {
588
+ kind: "text",
589
+ input: body.text,
590
+ newline: body.newline === true,
591
+ });
592
+ if (!policyCheck.allowed) {
593
+ return Response.json(
594
+ {
595
+ error: policyCheck.error || "Input blocked by policy",
596
+ policyAction: policyCheck.policyAction,
597
+ provider: policyCheck.provider,
598
+ },
599
+ { status: policyCheck.status ?? 403 }
600
+ );
601
+ }
602
+
603
+ // Send the text
604
+ await ctx.sessionManager.sendText(sessionId, body.text);
605
+
606
+ // If newline requested, flush writes and send Enter key
607
+ if (body.newline) {
608
+ // Flush any batched writes before sending Enter
609
+ ctx.sessionManager.flushWrites(sessionId);
610
+ // Small delay to ensure flush completed
611
+ await new Promise((resolve) => setTimeout(resolve, 20));
612
+ // Send Enter key
613
+ await ctx.sessionManager.sendKeys(sessionId, ["enter"]);
614
+ }
615
+
616
+ return Response.json({ success: true });
617
+ } catch (error) {
618
+ return errorResponse(error);
619
+ }
620
+ }
621
+
622
+ export async function handleSendKeys(
623
+ req: Request,
624
+ ctx: RouteContext,
625
+ sessionId: string
626
+ ): Promise<Response> {
627
+ let body: any;
628
+ try {
629
+ body = await req.json();
630
+ } catch {
631
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
632
+ }
633
+
634
+ // Validate keys array
635
+ const keysValidation = validateKeys(body.keys);
636
+ if (!keysValidation.valid) {
637
+ return Response.json({ error: keysValidation.error }, { status: 400 });
638
+ }
639
+
640
+ try {
641
+ const policyCheck = await enforceInputPolicyPreflight(ctx, sessionId, {
642
+ kind: "keys",
643
+ keys: body.keys,
644
+ });
645
+ if (!policyCheck.allowed) {
646
+ return Response.json(
647
+ {
648
+ error: policyCheck.error || "Input blocked by policy",
649
+ policyAction: policyCheck.policyAction,
650
+ provider: policyCheck.provider,
651
+ },
652
+ { status: policyCheck.status ?? 403 }
653
+ );
654
+ }
655
+
656
+ await ctx.sessionManager.sendKeys(sessionId, body.keys);
657
+
658
+ return Response.json({ success: true });
659
+ } catch (error) {
660
+ return errorResponse(error);
661
+ }
662
+ }
663
+
664
+ export async function handleClaudeHook(req: Request, ctx: RouteContext): Promise<Response> {
665
+ return handleHookEvent(req, {
666
+ db: ctx.db,
667
+ eventBus: ctx.eventBus,
668
+ sessionManager: ctx.sessionManager,
669
+ policyEngine: ctx.policyEngine,
670
+ auditLogger: ctx.auditLogger,
671
+ hookSecret: ctx.hookSecret,
672
+ });
673
+ }
674
+
675
+ export async function handleGetTranscriptEvents(
676
+ req: Request,
677
+ ctx: RouteContext,
678
+ sessionId: string
679
+ ): Promise<Response> {
680
+ const url = new URL(req.url);
681
+ const since = url.searchParams.get("since");
682
+ const before = url.searchParams.get("before");
683
+ const limit = url.searchParams.get("limit");
684
+ const source = url.searchParams.get("source");
685
+ const type = url.searchParams.get("type");
686
+ const order = url.searchParams.get("order");
687
+
688
+ try {
689
+ const parsedLimit = limit ? parseInt(limit, 10) : undefined;
690
+ const parsedSource: EventSource | undefined =
691
+ source === "pty" || source === "hook" || source === "transcript" || source === "statusline"
692
+ ? source
693
+ : undefined;
694
+
695
+ const events = ctx.db.getEventsBySessionId(sessionId, {
696
+ source: parsedSource,
697
+ type: type || undefined,
698
+ since: since ? parseInt(since, 10) : undefined,
699
+ before: before ? parseInt(before, 10) : undefined,
700
+ limit: parsedLimit ? parsedLimit + 1 : undefined,
701
+ order: order === "asc" || order === "desc" ? order : undefined,
702
+ });
703
+
704
+ // Determine hasMore by checking if we got more than the requested limit
705
+ let hasMore = false;
706
+ if (parsedLimit && events.length > parsedLimit) {
707
+ hasMore = true;
708
+ events.pop();
709
+ }
710
+
711
+ return Response.json({ events, hasMore });
712
+ } catch (error) {
713
+ return errorResponse(error);
714
+ }
715
+ }
716
+
717
+ export async function handleSwitchModel(
718
+ req: Request,
719
+ ctx: RouteContext,
720
+ sessionId: string
721
+ ): Promise<Response> {
722
+ const session = ctx.sessionManager.getSession(sessionId);
723
+ if (!session) {
724
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
725
+ }
726
+
727
+ const capabilities = getProviderDefinition(session.provider).capabilities;
728
+ if (!capabilities.supportsModelSwitch) {
729
+ return Response.json(
730
+ {
731
+ error: `Model switching is not supported for provider ${session.provider}`,
732
+ provider: session.provider,
733
+ supportsModelSwitch: false,
734
+ },
735
+ { status: 409 }
736
+ );
737
+ }
738
+
739
+ let body: any;
740
+ try {
741
+ body = await req.json();
742
+ } catch {
743
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
744
+ }
745
+
746
+ // Validate model field
747
+ if (typeof body.model !== "string") {
748
+ return Response.json({ error: "Missing required field: model" }, { status: 400 });
749
+ }
750
+
751
+ const modelValidation = validateModel(body.model);
752
+ if (!modelValidation.valid) {
753
+ return Response.json({ error: modelValidation.error }, { status: 400 });
754
+ }
755
+
756
+ try {
757
+ await ctx.sessionManager.switchModel(sessionId, body.model);
758
+
759
+ return Response.json({ success: true });
760
+ } catch (error) {
761
+ return errorResponse(error);
762
+ }
763
+ }
764
+
765
+ export async function handleGetModel(
766
+ _req: Request,
767
+ ctx: RouteContext,
768
+ sessionId: string
769
+ ): Promise<Response> {
770
+ try {
771
+ const session = ctx.sessionManager.getSession(sessionId);
772
+ if (!session) {
773
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
774
+ }
775
+ const capabilities = getProviderDefinition(session.provider).capabilities;
776
+ const model = ctx.sessionManager.getCurrentModel(sessionId);
777
+
778
+ return Response.json({
779
+ model,
780
+ provider: session.provider,
781
+ supportsModelSwitch: capabilities.supportsModelSwitch,
782
+ });
783
+ } catch (error) {
784
+ return errorResponse(error);
785
+ }
786
+ }
787
+
788
+ export async function handleGetSessionPolicy(
789
+ _req: Request,
790
+ ctx: RouteContext,
791
+ sessionId: string
792
+ ): Promise<Response> {
793
+ try {
794
+ // Verify session exists
795
+ const session = ctx.sessionManager.getSession(sessionId);
796
+ if (!session) {
797
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
798
+ }
799
+
800
+ // Get policies for this session (sessionId filter)
801
+ const policies = ctx.db.listPolicies({ sessionId });
802
+
803
+ return Response.json({ policies });
804
+ } catch (error) {
805
+ return errorResponse(error);
806
+ }
807
+ }
808
+
809
+ export async function handleGetSessionOutput(
810
+ _req: Request,
811
+ ctx: RouteContext,
812
+ sessionId: string
813
+ ): Promise<Response> {
814
+ try {
815
+ const output = await ctx.sessionManager.getSessionOutput(sessionId);
816
+ return Response.json({ output });
817
+ } catch (error) {
818
+ if (error instanceof SessionNotFoundError) {
819
+ const dbSession = ctx.db.getSession(sessionId);
820
+ if (!dbSession) {
821
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
822
+ }
823
+
824
+ return Response.json(
825
+ {
826
+ error: `Session is not actively managed: ${sessionId}`,
827
+ status: dbSession.status,
828
+ },
829
+ { status: 409 }
830
+ );
831
+ }
832
+
833
+ return errorResponse(error);
834
+ }
835
+ }
836
+
837
+ export async function handleResizeSession(
838
+ req: Request,
839
+ ctx: RouteContext,
840
+ sessionId: string
841
+ ): Promise<Response> {
842
+ let body: any;
843
+ try {
844
+ body = await req.json();
845
+ } catch {
846
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
847
+ }
848
+
849
+ const cols = body.cols;
850
+ const rows = body.rows;
851
+ if (typeof cols !== "number" || typeof rows !== "number" || cols < 1 || rows < 1) {
852
+ return Response.json({ error: "cols and rows must be positive numbers" }, { status: 400 });
853
+ }
854
+
855
+ try {
856
+ await ctx.sessionManager.resizeSession(sessionId, cols, rows);
857
+ return Response.json({ success: true });
858
+ } catch {
859
+ // Session may be stopped — silently ignore resize for dead sessions
860
+ return Response.json({ success: false });
861
+ }
862
+ }
863
+
864
+ export async function handleSetSessionPolicy(
865
+ req: Request,
866
+ ctx: RouteContext,
867
+ sessionId: string
868
+ ): Promise<Response> {
869
+ try {
870
+ // Verify session exists
871
+ const session = ctx.sessionManager.getSession(sessionId);
872
+ if (!session) {
873
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
874
+ }
875
+
876
+ let body: any;
877
+ try {
878
+ body = await req.json();
879
+ } catch {
880
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
881
+ }
882
+
883
+ // Validate policy structure
884
+ if (!body.policy || typeof body.policy !== "object") {
885
+ return Response.json({ error: "Missing or invalid 'policy' field" }, { status: 400 });
886
+ }
887
+
888
+ // Policy structure should have name and rules
889
+ if (!(body.policy.name && Array.isArray(body.policy.rules))) {
890
+ return Response.json(
891
+ { error: "Policy must have 'name' (string) and 'rules' (array)" },
892
+ { status: 400 }
893
+ );
894
+ }
895
+
896
+ // Generate policy ID (session-id-policy format)
897
+ const policyId = `${sessionId}-policy`;
898
+
899
+ // Check if policy already exists for this session
900
+ const existingPolicies = ctx.db.listPolicies({ sessionId });
901
+
902
+ if (existingPolicies.length > 0) {
903
+ // Update existing policy
904
+ const existingPolicy = existingPolicies[0]; // Use first one
905
+ if (!existingPolicy) {
906
+ return Response.json({ error: "Failed to load existing policy" }, { status: 500 });
907
+ }
908
+ ctx.db.updatePolicy(existingPolicy.id, {
909
+ name: body.policy.name,
910
+ description: body.policy.description,
911
+ enabled: body.policy.enabled !== undefined ? body.policy.enabled : true,
912
+ priority: body.policy.priority !== undefined ? body.policy.priority : 50,
913
+ rules: body.policy.rules,
914
+ });
915
+
916
+ return Response.json({
917
+ success: true,
918
+ policyId: existingPolicy.id,
919
+ action: "updated",
920
+ });
921
+ } else {
922
+ // Create new policy
923
+ ctx.db.createPolicy({
924
+ id: policyId,
925
+ name: body.policy.name,
926
+ description: body.policy.description,
927
+ enabled: body.policy.enabled !== undefined ? body.policy.enabled : true,
928
+ priority: body.policy.priority !== undefined ? body.policy.priority : 50,
929
+ sessionId, // Link to this session
930
+ rules: body.policy.rules,
931
+ });
932
+
933
+ return Response.json({
934
+ success: true,
935
+ policyId,
936
+ action: "created",
937
+ });
938
+ }
939
+ } catch (error) {
940
+ return errorResponse(error);
941
+ }
942
+ }
943
+
944
+ export async function handleUploadImage(
945
+ req: Request,
946
+ ctx: RouteContext,
947
+ sessionId: string
948
+ ): Promise<Response> {
949
+ // Verify session exists (in-memory or DB)
950
+ const session = ctx.sessionManager.getSession(sessionId);
951
+ if (!session) {
952
+ const dbSession = ctx.db.getSession(sessionId);
953
+ if (!dbSession) {
954
+ return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
955
+ }
956
+ }
957
+
958
+ let formData: Awaited<ReturnType<Request["formData"]>>;
959
+ try {
960
+ formData = await req.formData();
961
+ } catch {
962
+ return Response.json(
963
+ { error: "Invalid multipart form data. Send image as 'image' field." },
964
+ { status: 400 }
965
+ );
966
+ }
967
+
968
+ const validation = validateImageUpload(formData);
969
+ if (!(validation.valid && validation.file)) {
970
+ return Response.json({ error: validation.error }, { status: 400 });
971
+ }
972
+
973
+ const imageDir = ctx.sessionManager.getImageDir(sessionId);
974
+ const filename = `upload-${Date.now()}-${validation.sanitizedName}`;
975
+ const filePath = `${imageDir}/${filename}`;
976
+
977
+ try {
978
+ // Ensure directory exists (may have been cleaned up if session was stopped)
979
+ const fs = await import("node:fs");
980
+ if (!fs.existsSync(imageDir)) {
981
+ fs.mkdirSync(imageDir, { recursive: true, mode: 0o700 });
982
+ }
983
+ try {
984
+ fs.chmodSync(imageDir, 0o700);
985
+ } catch {
986
+ // best-effort on non-POSIX filesystems
987
+ }
988
+
989
+ // Write the file using Bun.write for optimal performance
990
+ await Bun.write(filePath, validation.file);
991
+ try {
992
+ fs.chmodSync(filePath, 0o600);
993
+ } catch {
994
+ // best-effort on non-POSIX filesystems
995
+ }
996
+
997
+ return Response.json({
998
+ path: filePath,
999
+ filename: validation.file.name || validation.sanitizedName,
1000
+ });
1001
+ } catch (error) {
1002
+ return errorResponse(error);
1003
+ }
1004
+ }