Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit90c7531unknown_key

feat(claude-web): /:owner/:repo/claude — iPad-friendly Claude Code sessions

feat(claude-web): /:owner/:repo/claude — iPad-friendly Claude Code sessions

Per-repo interactive Claude Code sessions runnable from any browser.
v1 admin-only, single-server compute (subprocess on the gluecron web
host). Each session owns a per-session git clone under
CLAUDE_WEB_WORKDIR; subsequent turns pass --resume <session-uuid> so
prior context survives without re-sending the transcript.

The composer streams Claude's stream-json output over SSE; a 30-line
inline EventSource client appends chunks as they arrive. User messages
persist *before* the stream opens so a flaky iPad cell connection
mid-turn doesn't lose the question. Workdir clone is lazy on first turn.

Container isolation is deferred to v2 (the existing dev_envs sandbox
stub becomes the runtime). For v1 the operator is admin-only, the env
is scrubbed before spawn (DB / SMTP creds dropped, ANTHROPIC_API_KEY +
PATH + HOME + CLAUDE_* kept), and a 5-minute hard cap kills runaway
turns.

- drizzle/0074_claude_web_sessions.sql: claude_web_sessions + messages
- src/lib/claude-web-session.ts: subprocess seam, runTurn AsyncGenerator,
  ensureWorkdir (lazy git clone), env scrubbing, session-id extraction
  from stream-json init events, CRUD helpers
- src/routes/claude-web.tsx: list + new + chat UI + SSE stream + delete
- 10 new tests covering spawn command shape, --resume passthrough,
  session-id extraction, env-passthrough whitelist
Claude committed on May 27, 2026Parent: 783dd46
7 files changed+1149090c7531d2e69c0eec646fb108053806e76342b64
7 changed files+1149−0
Modified.env.example+8−0View fileUnifiedSplit
7575# Generate with: openssl rand -hex 32. If unset, all server-target
7676# operations refuse cleanly (no panics, no plaintext fallbacks).
7777SERVER_TARGETS_KEY=
78# Block CW — Claude on the web (/:owner/:repo/claude).
79# Per-session working directories live under this dir on the web server.
80# Default: /var/lib/gluecron/claude-web. Should be on a disk with room
81# for full repo clones and writable by the gluecron service user.
82CLAUDE_WEB_WORKDIR=
83# Path to the Claude Code CLI binary. Default: `claude` (i.e. on PATH).
84# Override if `claude` is installed somewhere non-standard on the box.
85CLAUDE_BIN=
7886# Set to "production" or "test" to override NODE_ENV for Bun.
7987BUN_ENV=
8088# Injected at build time; the deployed git commit SHA for the platform-status endpoint.
Addeddrizzle/0074_claude_web_sessions.sql+56−0View fileUnifiedSplit
1-- Gluecron migration 0074: Claude-on-the-web sessions (Block CW).
2--
3-- Per-repo interactive Claude Code sessions runnable from any browser
4-- (including iPad). Each session owns:
5-- - A working directory on the gluecron web server (cloned from the
6-- repo's bare git store) where Claude can read + edit files.
7-- - A persistent transcript of user/assistant messages.
8-- - The Claude CLI session UUID so subsequent turns `--resume` it and
9-- keep full prior-turn context without us re-sending the transcript.
10--
11-- Admin-only in v1 (the route gates on isSiteAdmin). Customer-facing
12-- rollout will scope by owner_user_id and run in real containers — for
13-- v1 every session shares the web server's compute.
14--
15-- All DDL wrapped in DO blocks so partial replays are safe.
16
17--> statement-breakpoint
18DO $$
19BEGIN
20 CREATE TABLE IF NOT EXISTS "claude_web_sessions" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
23 "owner_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
24 "title" text NOT NULL DEFAULT 'New session',
25 "branch" text NOT NULL DEFAULT 'main',
26 "workdir_path" text NOT NULL,
27 "claude_session_id" text,
28 "status" text NOT NULL DEFAULT 'cold',
29 "last_active_at" timestamptz NOT NULL DEFAULT now(),
30 "created_at" timestamptz NOT NULL DEFAULT now()
31 );
32 CREATE INDEX IF NOT EXISTS "claude_web_sessions_repo_idx"
33 ON "claude_web_sessions"("repository_id", "last_active_at" DESC);
34 CREATE INDEX IF NOT EXISTS "claude_web_sessions_owner_idx"
35 ON "claude_web_sessions"("owner_user_id", "last_active_at" DESC);
36EXCEPTION WHEN OTHERS THEN
37 RAISE NOTICE 'claude_web_sessions create failed (%); feature will be unavailable', SQLERRM;
38END $$;
39
40--> statement-breakpoint
41DO $$
42BEGIN
43 CREATE TABLE IF NOT EXISTS "claude_web_messages" (
44 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
45 "session_id" uuid NOT NULL REFERENCES "claude_web_sessions"("id") ON DELETE CASCADE,
46 "role" text NOT NULL,
47 "body" text NOT NULL,
48 "exit_code" integer,
49 "duration_ms" integer,
50 "created_at" timestamptz NOT NULL DEFAULT now()
51 );
52 CREATE INDEX IF NOT EXISTS "claude_web_messages_session_idx"
53 ON "claude_web_messages"("session_id", "created_at");
54EXCEPTION WHEN OTHERS THEN
55 RAISE NOTICE 'claude_web_messages create failed (%);', SQLERRM;
56END $$;
Addedsrc/__tests__/claude-web-session.test.ts+210−0View fileUnifiedSplit
1/**
2 * Unit tests for src/lib/claude-web-session.ts.
3 *
4 * We don't exercise the real `claude` CLI here — the spawn seam is
5 * overridden so we can verify the command shape, --resume passthrough,
6 * stream-json session-id parsing, and the env-scrubbing whitelist.
7 */
8
9import { describe, it, expect, afterEach, beforeEach } from "bun:test";
10import {
11 __setSpawnForTests,
12 __test,
13 claudeBinary,
14 claudeWebRoot,
15 runTurn,
16 sessionWorkdir,
17 type SpawnFn,
18} from "../lib/claude-web-session";
19import type { ClaudeWebSession } from "../db/schema";
20
21const originalRoot = process.env.CLAUDE_WEB_WORKDIR;
22const originalBin = process.env.CLAUDE_BIN;
23const originalApiKey = process.env.ANTHROPIC_API_KEY;
24
25beforeEach(() => {
26 process.env.CLAUDE_WEB_WORKDIR = "/tmp/cw-test";
27 process.env.CLAUDE_BIN = "claude-test";
28});
29
30afterEach(() => {
31 __setSpawnForTests(null);
32 for (const [k, v] of [
33 ["CLAUDE_WEB_WORKDIR", originalRoot],
34 ["CLAUDE_BIN", originalBin],
35 ["ANTHROPIC_API_KEY", originalApiKey],
36 ] as const) {
37 if (v === undefined) delete process.env[k];
38 else process.env[k] = v;
39 }
40});
41
42function fakeSession(overrides: Partial<ClaudeWebSession> = {}): ClaudeWebSession {
43 return {
44 id: "00000000-0000-0000-0000-000000000099",
45 repositoryId: "00000000-0000-0000-0000-000000000001",
46 ownerUserId: "00000000-0000-0000-0000-000000000002",
47 title: "test session",
48 branch: "main",
49 workdirPath: "/tmp/cw-test/00000000-0000-0000-0000-000000000099",
50 claudeSessionId: null,
51 status: "cold",
52 lastActiveAt: new Date(),
53 createdAt: new Date(),
54 ...overrides,
55 } as ClaudeWebSession;
56}
57
58describe("claudeWebRoot + sessionWorkdir + claudeBinary", () => {
59 it("reads from env at call time", () => {
60 expect(claudeWebRoot()).toBe("/tmp/cw-test");
61 expect(claudeBinary()).toBe("claude-test");
62 expect(sessionWorkdir("abc")).toBe("/tmp/cw-test/abc");
63 });
64
65 it("defaults sensibly when env unset", () => {
66 delete process.env.CLAUDE_WEB_WORKDIR;
67 delete process.env.CLAUDE_BIN;
68 expect(claudeWebRoot()).toBe("/var/lib/gluecron/claude-web");
69 expect(claudeBinary()).toBe("claude");
70 });
71
72 it("strips trailing slash from workdir root", () => {
73 process.env.CLAUDE_WEB_WORKDIR = "/tmp/cw-test/";
74 expect(claudeWebRoot()).toBe("/tmp/cw-test");
75 });
76});
77
78describe("runTurn", () => {
79 it("invokes the claude binary with --print and prompt, without --resume on first turn", async () => {
80 const captured: { cmd: string[]; cwd: string } = { cmd: [], cwd: "" };
81 const handle = makeHandle(['{"type":"text","text":"hi"}\n'], 0, "");
82 __setSpawnForTests((cmd, opts) => {
83 captured.cmd = cmd;
84 captured.cwd = opts.cwd;
85 return handle;
86 });
87
88 const events = await collect(runTurn({
89 session: fakeSession(),
90 ownerName: "you",
91 repoName: "repo",
92 prompt: "hello there",
93 }));
94
95 expect(captured.cmd[0]).toBe("claude-test");
96 expect(captured.cmd).toContain("--print");
97 expect(captured.cmd).toContain("--output-format");
98 expect(captured.cmd).toContain("stream-json");
99 expect(captured.cmd).not.toContain("--resume");
100 expect(captured.cmd[captured.cmd.length - 1]).toBe("hello there");
101 expect(captured.cwd).toBe("/tmp/cw-test/00000000-0000-0000-0000-000000000099");
102
103 const chunks = events.filter((e) => e.chunk).map((e) => e.chunk).join("");
104 expect(chunks).toContain("hi");
105 const done = events.find((e) => e.done);
106 expect(done?.done?.exitCode).toBe(0);
107 });
108
109 it("passes --resume <id> when the session already has a claudeSessionId", async () => {
110 const captured: { cmd: string[] } = { cmd: [] };
111 __setSpawnForTests((cmd) => {
112 captured.cmd = cmd;
113 return makeHandle(["ok\n"], 0, "");
114 });
115
116 await collect(runTurn({
117 session: fakeSession({
118 claudeSessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
119 }),
120 ownerName: "you",
121 repoName: "repo",
122 prompt: "next turn",
123 }));
124
125 const i = captured.cmd.indexOf("--resume");
126 expect(i).toBeGreaterThan(-1);
127 expect(captured.cmd[i + 1]).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
128 });
129
130 it("extracts the Claude session UUID from a stream-json init chunk", async () => {
131 __setSpawnForTests(() =>
132 makeHandle(
133 [
134 '{"type":"system","subtype":"init","session_id":"12345678-aaaa-bbbb-cccc-1234567890ab"}\n',
135 '{"type":"text","text":"hello"}\n',
136 ],
137 0,
138 ""
139 )
140 );
141 const events = await collect(runTurn({
142 session: fakeSession(),
143 ownerName: "you",
144 repoName: "repo",
145 prompt: "p",
146 }));
147 const done = events.find((e) => e.done);
148 expect(done?.done?.claudeSessionId).toBe(
149 "12345678-aaaa-bbbb-cccc-1234567890ab"
150 );
151 });
152
153 it("returns the subprocess exit code on the done event", async () => {
154 __setSpawnForTests(() => makeHandle(["x"], 7, "boom"));
155 const events = await collect(runTurn({
156 session: fakeSession(),
157 ownerName: "y",
158 repoName: "r",
159 prompt: "p",
160 }));
161 const done = events.find((e) => e.done);
162 expect(done?.done?.exitCode).toBe(7);
163 expect(done?.done?.stderr).toBe("boom");
164 });
165});
166
167describe("passthroughEnv", () => {
168 it("keeps PATH and ANTHROPIC_API_KEY when set", () => {
169 process.env.ANTHROPIC_API_KEY = "sk-test";
170 const env = __test.passthroughEnv();
171 expect(env.PATH).toBeDefined();
172 expect(env.ANTHROPIC_API_KEY).toBe("sk-test");
173 });
174
175 it("strips DATABASE_URL even if set", () => {
176 process.env.DATABASE_URL = "postgresql://x";
177 const env = __test.passthroughEnv();
178 expect(env.DATABASE_URL).toBeUndefined();
179 delete process.env.DATABASE_URL;
180 });
181
182 it("keeps any CLAUDE_* prefix var", () => {
183 process.env.CLAUDE_CUSTOM_FLAG = "yes";
184 const env = __test.passthroughEnv();
185 expect(env.CLAUDE_CUSTOM_FLAG).toBe("yes");
186 delete process.env.CLAUDE_CUSTOM_FLAG;
187 });
188});
189
190// ─── helpers ────────────────────────────────────────────────────────────────
191
192function makeHandle(
193 chunks: string[],
194 exitCode: number,
195 stderr: string
196): ReturnType<SpawnFn> {
197 return {
198 stdout: (async function* () {
199 for (const c of chunks) yield c;
200 })(),
201 done: Promise.resolve({ exitCode, stderr }),
202 kill: () => {},
203 };
204}
205
206async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
207 const out: T[] = [];
208 for await (const ev of gen) out.push(ev);
209 return out;
210}
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
8888import adminDeploysRoutes from "./routes/admin-deploys";
8989import adminDeploysPageRoutes from "./routes/admin-deploys-page";
9090import adminServerTargetsRoutes from "./routes/admin-server-targets";
91import claudeWebRoutes from "./routes/claude-web";
9192import adminOpsRoutes from "./routes/admin-ops";
9293import adminSelfHostRoutes from "./routes/admin-self-host";
9394import adminDiagnoseRoutes from "./routes/admin-diagnose";
566567app.route("/", adminDeploysRoutes);
567568app.route("/", adminDeploysPageRoutes);
568569app.route("/", adminServerTargetsRoutes);
570app.route("/", claudeWebRoutes);
569571// Note: adminOpsRoutes is mounted earlier (before insightRoutes) — see comment above.
570572app.route("/", advisoriesRoutes);
571573app.route("/", aiChangelogRoutes);
Modifiedsrc/db/schema.ts+75−0View fileUnifiedSplit
39433943
39443944export type ServerTargetAudit = typeof serverTargetAudit.$inferSelect;
39453945export type NewServerTargetAudit = typeof serverTargetAudit.$inferInsert;
3946
3947// ---------------------------------------------------------------------------
3948// Block CW — Claude on the web (drizzle/0074_claude_web_sessions.sql)
3949//
3950// Per-repo interactive Claude Code sessions runnable from any browser.
3951// Each session owns a working dir on the web server (a fresh git clone of
3952// the repo's bare store) and persists turn-by-turn transcripts so an iPad
3953// user can resume the same conversation later from a laptop.
3954//
3955// v1 admin-only; v2 will scope by owner_user_id and isolate per-session
3956// containers. Schema is forward-compatible with both.
3957// ---------------------------------------------------------------------------
3958
3959export const claudeWebSessions = pgTable(
3960 "claude_web_sessions",
3961 {
3962 id: uuid("id").primaryKey().defaultRandom(),
3963 repositoryId: uuid("repository_id")
3964 .notNull()
3965 .references(() => repositories.id, { onDelete: "cascade" }),
3966 ownerUserId: uuid("owner_user_id")
3967 .notNull()
3968 .references(() => users.id, { onDelete: "cascade" }),
3969 title: text("title").default("New session").notNull(),
3970 branch: text("branch").default("main").notNull(),
3971 workdirPath: text("workdir_path").notNull(),
3972 claudeSessionId: text("claude_session_id"),
3973 status: text("status").default("cold").notNull(),
3974 lastActiveAt: timestamp("last_active_at", { withTimezone: true })
3975 .defaultNow()
3976 .notNull(),
3977 createdAt: timestamp("created_at", { withTimezone: true })
3978 .defaultNow()
3979 .notNull(),
3980 },
3981 (table) => [
3982 index("claude_web_sessions_repo_idx").on(
3983 table.repositoryId,
3984 table.lastActiveAt
3985 ),
3986 index("claude_web_sessions_owner_idx").on(
3987 table.ownerUserId,
3988 table.lastActiveAt
3989 ),
3990 ]
3991);
3992
3993export type ClaudeWebSession = typeof claudeWebSessions.$inferSelect;
3994export type NewClaudeWebSession = typeof claudeWebSessions.$inferInsert;
3995
3996export const claudeWebMessages = pgTable(
3997 "claude_web_messages",
3998 {
3999 id: uuid("id").primaryKey().defaultRandom(),
4000 sessionId: uuid("session_id")
4001 .notNull()
4002 .references(() => claudeWebSessions.id, { onDelete: "cascade" }),
4003 role: text("role").notNull(),
4004 body: text("body").notNull(),
4005 exitCode: integer("exit_code"),
4006 durationMs: integer("duration_ms"),
4007 createdAt: timestamp("created_at", { withTimezone: true })
4008 .defaultNow()
4009 .notNull(),
4010 },
4011 (table) => [
4012 index("claude_web_messages_session_idx").on(
4013 table.sessionId,
4014 table.createdAt
4015 ),
4016 ]
4017);
4018
4019export type ClaudeWebMessage = typeof claudeWebMessages.$inferSelect;
4020export type NewClaudeWebMessage = typeof claudeWebMessages.$inferInsert;
Addedsrc/lib/claude-web-session.ts+404−0View fileUnifiedSplit
1/**
2 * Claude-on-the-web runtime (Block CW).
3 *
4 * Drives one turn of an interactive Claude Code session running on the
5 * gluecron web server. Each turn:
6 * 1. Resolves (and lazily clones) the session's working directory.
7 * 2. Spawns `claude` as a subprocess pointed at that workdir, with
8 * --resume <session-uuid> for follow-up turns so prior context is
9 * preserved without us re-sending the transcript.
10 * 3. Streams stdout chunks back via an AsyncIterable the route layer
11 * hands to an SSE response.
12 * 4. Persists the assistant turn + updates the Claude session UUID +
13 * the last_active_at watermark.
14 *
15 * v1 design rules:
16 * - Admin-only — gated by the route, not this lib.
17 * - Single shared compute: every session shares the web server's CPU
18 * and disk. We cap a single turn at MAX_TURN_MS so a runaway prompt
19 * can't pin the box forever.
20 * - No container: workdir is a plain directory under CLAUDE_WEB_WORKDIR
21 * (default /var/lib/gluecron/claude-web). Caller is responsible for
22 * not pointing this at a tenant-shared filesystem.
23 * - Anthropic creds: we DO NOT pass ANTHROPIC_API_KEY through the
24 * environment if the operator has logged in `claude` interactively
25 * (the CLI manages its own creds in ~/.claude). If the env var is
26 * set, we pass it through unchanged.
27 *
28 * Test seam: `__setSpawnForTests` lets unit tests intercept the spawn
29 * without actually running the Claude CLI.
30 */
31
32import { mkdir, stat } from "fs/promises";
33import { join } from "path";
34import { db } from "../db";
35import {
36 claudeWebMessages,
37 claudeWebSessions,
38 type ClaudeWebSession,
39} from "../db/schema";
40import { and, eq } from "drizzle-orm";
41import { getRepoPath } from "../git/repository";
42
43const MAX_TURN_MS = 5 * 60_000;
44
45export function claudeWebRoot(): string {
46 return (
47 process.env.CLAUDE_WEB_WORKDIR ||
48 "/var/lib/gluecron/claude-web"
49 ).replace(/\/$/, "");
50}
51
52export function claudeBinary(): string {
53 return process.env.CLAUDE_BIN || "claude";
54}
55
56// ---------------------------------------------------------------------------
57// Spawn seam
58// ---------------------------------------------------------------------------
59
60export interface SpawnHandle {
61 /** Async iterable of utf-8 stdout chunks. */
62 stdout: AsyncIterable<string>;
63 /** Resolves to the final exit code + collected stderr after stdout ends. */
64 done: Promise<{ exitCode: number; stderr: string }>;
65 /** Kills the underlying subprocess. */
66 kill: () => void;
67}
68
69export type SpawnFn = (
70 cmd: string[],
71 opts: { cwd: string; env: Record<string, string> }
72) => SpawnHandle;
73
74const defaultSpawn: SpawnFn = (cmd, opts) => {
75 const proc = Bun.spawn(cmd, {
76 cwd: opts.cwd,
77 env: opts.env,
78 stdin: "ignore",
79 stdout: "pipe",
80 stderr: "pipe",
81 });
82 const stdout = (async function* () {
83 const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
84 const dec = new TextDecoder();
85 try {
86 while (true) {
87 const { done, value } = await reader.read();
88 if (done) break;
89 if (value && value.length) yield dec.decode(value, { stream: true });
90 }
91 const tail = dec.decode();
92 if (tail) yield tail;
93 } finally {
94 reader.releaseLock();
95 }
96 })();
97 const done = (async () => {
98 const [exitCode, stderr] = await Promise.all([
99 proc.exited,
100 new Response(proc.stderr).text(),
101 ]);
102 return { exitCode, stderr };
103 })();
104 return { stdout, done, kill: () => proc.kill() };
105};
106
107let _spawn: SpawnFn = defaultSpawn;
108export function __setSpawnForTests(fn: SpawnFn | null): void {
109 _spawn = fn ?? defaultSpawn;
110}
111
112// ---------------------------------------------------------------------------
113// Workdir
114// ---------------------------------------------------------------------------
115
116export function sessionWorkdir(sessionId: string): string {
117 return join(claudeWebRoot(), sessionId);
118}
119
120/**
121 * Make sure the session's working dir exists as a fresh clone of the
122 * repo's bare store at the given branch. Idempotent — if the dir already
123 * has a `.git` folder, we leave it alone and the operator's existing
124 * working state is preserved across turns.
125 *
126 * Uses `git clone --branch <branch> --depth 1 <bare> <workdir>` so the
127 * initial materialisation is fast even on huge repos. Operators can
128 * `git fetch --unshallow` from inside the session if they need history.
129 */
130export async function ensureWorkdir(
131 session: ClaudeWebSession,
132 ownerName: string,
133 repoName: string,
134 spawn: SpawnFn = _spawn
135): Promise<{ ok: true } | { ok: false; error: string }> {
136 const root = claudeWebRoot();
137 try {
138 await mkdir(root, { recursive: true });
139 } catch (err) {
140 return {
141 ok: false,
142 error: `mkdir ${root}: ${err instanceof Error ? err.message : err}`,
143 };
144 }
145 const workdir = session.workdirPath;
146 // If .git already exists in the workdir, the clone is done.
147 try {
148 const s = await stat(join(workdir, ".git"));
149 if (s.isDirectory()) return { ok: true };
150 } catch {
151 /* fall through to clone */
152 }
153
154 const bare = getRepoPath(ownerName, repoName);
155 const handle = spawn(
156 ["git", "clone", "--branch", session.branch, "--depth", "1", bare, workdir],
157 { cwd: root, env: scrubbedEnv() }
158 );
159 // Drain stdout (clone is quiet, but we still need to consume).
160 for await (const _ of handle.stdout) {
161 /* ignore */
162 }
163 const { exitCode, stderr } = await handle.done;
164 if (exitCode !== 0) {
165 return { ok: false, error: stderr.trim() || `git clone exit ${exitCode}` };
166 }
167 return { ok: true };
168}
169
170// ---------------------------------------------------------------------------
171// Turn driver
172// ---------------------------------------------------------------------------
173
174export interface TurnInput {
175 session: ClaudeWebSession;
176 ownerName: string;
177 repoName: string;
178 prompt: string;
179}
180
181export interface TurnEvent {
182 /** Streaming text chunk from Claude's stdout. */
183 chunk?: string;
184 /** Terminal event — call once, after stdout ends. */
185 done?: {
186 exitCode: number;
187 durationMs: number;
188 /** Claude CLI session UUID extracted from the run (when present). */
189 claudeSessionId?: string;
190 stderr: string;
191 };
192}
193
194/**
195 * Run a single conversational turn against Claude. Yields stdout chunks
196 * as they arrive, then a single `done` event with metadata. The caller
197 * (typically the SSE route) is responsible for serialising events onto
198 * the wire and persisting the final assistant body.
199 *
200 * Honours `MAX_TURN_MS` — if the subprocess hasn't finished by then we
201 * call kill() and emit done with exitCode=124 (matching `timeout`).
202 */
203export async function* runTurn(
204 input: TurnInput,
205 spawn: SpawnFn = _spawn
206): AsyncGenerator<TurnEvent, void, void> {
207 const start = Date.now();
208 const cmd = [
209 claudeBinary(),
210 "--print",
211 "--output-format",
212 "stream-json",
213 ...(input.session.claudeSessionId
214 ? ["--resume", input.session.claudeSessionId]
215 : []),
216 input.prompt,
217 ];
218
219 const handle = spawn(cmd, {
220 cwd: input.session.workdirPath,
221 env: passthroughEnv(),
222 });
223
224 const timer = setTimeout(() => {
225 try {
226 handle.kill();
227 } catch {
228 /* ignore */
229 }
230 }, MAX_TURN_MS);
231
232 let sessionId: string | undefined;
233 try {
234 for await (const chunk of handle.stdout) {
235 // Best-effort parse for the Claude session UUID inside stream-json
236 // events. The CLI emits a `"session_id":"<uuid>"` key on its init
237 // event; we just grep the chunk text so format drift doesn't break.
238 if (!sessionId) {
239 const m = chunk.match(/"session_id"\s*:\s*"([0-9a-f-]{32,36})"/i);
240 if (m) sessionId = m[1];
241 }
242 yield { chunk };
243 }
244 } finally {
245 clearTimeout(timer);
246 }
247
248 const { exitCode, stderr } = await handle.done;
249 yield {
250 done: {
251 exitCode,
252 durationMs: Date.now() - start,
253 claudeSessionId: sessionId,
254 stderr,
255 },
256 };
257}
258
259/**
260 * Env scrubbing for child subprocesses. Drops Postgres / SMTP / SSH
261 * creds the Claude binary doesn't need. Keeps ANTHROPIC_API_KEY, PATH,
262 * HOME, and the CLAUDE_* family.
263 */
264function passthroughEnv(): Record<string, string> {
265 const out: Record<string, string> = {};
266 const passlist = new Set([
267 "PATH",
268 "HOME",
269 "USER",
270 "LANG",
271 "LC_ALL",
272 "TERM",
273 "ANTHROPIC_API_KEY",
274 "ANTHROPIC_BASE_URL",
275 "ANTHROPIC_AUTH_TOKEN",
276 ]);
277 for (const [k, v] of Object.entries(process.env)) {
278 if (typeof v !== "string") continue;
279 if (passlist.has(k) || k.startsWith("CLAUDE_")) out[k] = v;
280 }
281 return out;
282}
283
284function scrubbedEnv(): Record<string, string> {
285 // For `git clone` we want even fewer — just PATH + HOME so git can
286 // find its config and ssh wrappers.
287 const out: Record<string, string> = {};
288 for (const k of ["PATH", "HOME", "USER", "LANG"]) {
289 if (typeof process.env[k] === "string") out[k] = process.env[k] as string;
290 }
291 // Disable any interactive prompt — clone should be non-interactive.
292 out.GIT_TERMINAL_PROMPT = "0";
293 return out;
294}
295
296// ---------------------------------------------------------------------------
297// Transcript persistence
298// ---------------------------------------------------------------------------
299
300export async function listMessages(sessionId: string) {
301 return db
302 .select()
303 .from(claudeWebMessages)
304 .where(eq(claudeWebMessages.sessionId, sessionId))
305 .orderBy(claudeWebMessages.createdAt);
306}
307
308export async function appendMessage(input: {
309 sessionId: string;
310 role: "user" | "assistant" | "system";
311 body: string;
312 exitCode?: number;
313 durationMs?: number;
314}): Promise<void> {
315 await db.insert(claudeWebMessages).values({
316 sessionId: input.sessionId,
317 role: input.role,
318 body: input.body,
319 exitCode: input.exitCode ?? null,
320 durationMs: input.durationMs ?? null,
321 });
322}
323
324export async function touchSession(input: {
325 sessionId: string;
326 claudeSessionId?: string;
327 status?: string;
328}): Promise<void> {
329 const set: Record<string, unknown> = { lastActiveAt: new Date() };
330 if (input.claudeSessionId) set.claudeSessionId = input.claudeSessionId;
331 if (input.status) set.status = input.status;
332 await db
333 .update(claudeWebSessions)
334 .set(set)
335 .where(eq(claudeWebSessions.id, input.sessionId));
336}
337
338export async function createSession(input: {
339 repositoryId: string;
340 ownerUserId: string;
341 title?: string;
342 branch?: string;
343}): Promise<ClaudeWebSession> {
344 // Insert first so we have the id for the workdir path.
345 const [row] = await db
346 .insert(claudeWebSessions)
347 .values({
348 repositoryId: input.repositoryId,
349 ownerUserId: input.ownerUserId,
350 title: input.title ?? "New session",
351 branch: input.branch ?? "main",
352 workdirPath: "pending",
353 status: "cold",
354 })
355 .returning();
356 if (!row) throw new Error("createSession: insert returned no row");
357 const workdir = sessionWorkdir(row.id);
358 const [updated] = await db
359 .update(claudeWebSessions)
360 .set({ workdirPath: workdir })
361 .where(eq(claudeWebSessions.id, row.id))
362 .returning();
363 return updated ?? { ...row, workdirPath: workdir };
364}
365
366export async function getSession(
367 id: string,
368 ownerUserId?: string
369): Promise<ClaudeWebSession | null> {
370 const conds = ownerUserId
371 ? and(
372 eq(claudeWebSessions.id, id),
373 eq(claudeWebSessions.ownerUserId, ownerUserId)
374 )
375 : eq(claudeWebSessions.id, id);
376 const [row] = await db
377 .select()
378 .from(claudeWebSessions)
379 .where(conds)
380 .limit(1);
381 return row ?? null;
382}
383
384export async function listSessionsForRepo(
385 repositoryId: string,
386 limit = 50
387): Promise<ClaudeWebSession[]> {
388 return db
389 .select()
390 .from(claudeWebSessions)
391 .where(eq(claudeWebSessions.repositoryId, repositoryId))
392 .limit(limit);
393}
394
395export async function deleteSession(id: string): Promise<void> {
396 await db.delete(claudeWebSessions).where(eq(claudeWebSessions.id, id));
397}
398
399/** Test-only access to internals. */
400export const __test = {
401 passthroughEnv,
402 scrubbedEnv,
403 MAX_TURN_MS,
404};
Addedsrc/routes/claude-web.tsx+394−0View fileUnifiedSplit
1/**
2 * Block CW — Claude on the web.
3 *
4 * GET /:owner/:repo/claude — session list + new-session form
5 * POST /:owner/:repo/claude — create session, redirect to detail
6 * GET /:owner/:repo/claude/:sessionId — chat UI (messages + composer)
7 * GET /:owner/:repo/claude/:sessionId/stream — SSE stream of a single turn
8 * POST /:owner/:repo/claude/:sessionId/delete — delete session
9 *
10 * v1 admin-only. The page renders server-side with a small inline
11 * EventSource client that POSTs nothing — the SSE GET is parameterised
12 * by `?prompt=...` so iPad keyboards (no JS fetch issues) just need to
13 * follow a link the form submits to. The endpoint persists the user
14 * message before opening the stream, so a flaky network mid-stream
15 * still leaves the question in the transcript.
16 */
17
18import { Hono } from "hono";
19import { and, eq } from "drizzle-orm";
20import { db } from "../db";
21import { repositories, users } from "../db/schema";
22import { Layout } from "../views/layout";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { isSiteAdmin } from "../lib/admin";
26import {
27 appendMessage,
28 createSession,
29 deleteSession,
30 ensureWorkdir,
31 getSession,
32 listMessages,
33 listSessionsForRepo,
34 runTurn,
35 touchSession,
36} from "../lib/claude-web-session";
37
38const claudeWeb = new Hono<AuthEnv>();
39claudeWeb.use("*", softAuth);
40
41async function gate(
42 c: any
43): Promise<{ userId: string; repoId: string; ownerName: string; repoName: string } | Response> {
44 const user = c.get("user");
45 if (!user) {
46 const target = encodeURIComponent(c.req.url);
47 return c.redirect(`/login?next=${target}`);
48 }
49 if (!(await isSiteAdmin(user.id))) {
50 return c.html(
51 <Layout title="Forbidden" user={user}>
52 <main style="max-width:640px;margin:40px auto;padding:0 20px">
53 <h1>403 — site admin required</h1>
54 <p>Claude on the web is admin-only in v1.</p>
55 </main>
56 </Layout>,
57 403
58 );
59 }
60 const ownerName = c.req.param("owner");
61 const repoName = c.req.param("repo");
62 const [row] = await db
63 .select({ id: repositories.id })
64 .from(repositories)
65 .innerJoin(users, eq(repositories.ownerId, users.id))
66 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
67 .limit(1);
68 if (!row) return c.notFound();
69 return { userId: user.id, repoId: row.id, ownerName, repoName };
70}
71
72const wrap =
73 "max-width:980px;margin:24px auto;padding:0 16px;color:#e5e7eb;font-family:system-ui,sans-serif";
74const card =
75 "background:#0e1117;border:1px solid #1f2937;border-radius:10px;padding:16px 18px;margin-bottom:14px";
76const inputStyle =
77 "width:100%;background:#0b0e13;border:1px solid #1f2937;color:#e5e7eb;padding:10px 12px;border-radius:6px;font-size:16px";
78const btn =
79 "background:#1f6feb;border:0;color:#fff;padding:10px 16px;border-radius:6px;cursor:pointer;font-size:15px";
80
81// ─── GET /:owner/:repo/claude ───────────────────────────────────────────────
82
83claudeWeb.get("/:owner/:repo/claude", async (c) => {
84 const g = await gate(c);
85 if (g instanceof Response) return g;
86 const user = c.get("user")!;
87 const sessions = await listSessionsForRepo(g.repoId);
88
89 return c.html(
90 <Layout title={`Claude — ${g.ownerName}/${g.repoName}`} user={user}>
91 <main style={wrap}>
92 <p style="margin:0 0 6px">
93 <a href={`/${g.ownerName}/${g.repoName}`} style="color:#9ca3af;text-decoration:none">
94 ← {g.ownerName}/{g.repoName}
95 </a>
96 </p>
97 <h1 style="margin:0 0 6px;font-size:22px">Claude on this repo</h1>
98 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px">
99 iPad-friendly Claude Code sessions on a per-session clone of the repo.
100 </p>
101
102 <form method="post" action={`/${g.ownerName}/${g.repoName}/claude`} style={card}>
103 <label style="display:block;font-size:13px;color:#9ca3af;margin-bottom:6px">
104 New session title
105 </label>
106 <div style="display:flex;gap:8px">
107 <input name="title" placeholder="What you want to work on" style={inputStyle} />
108 <input name="branch" placeholder="main" style={inputStyle + ";max-width:160px"} />
109 <button type="submit" style={btn}>Start</button>
110 </div>
111 </form>
112
113 <div style={card}>
114 <h2 style="margin:0 0 10px;font-size:15px;color:#cbd5e1">Sessions</h2>
115 {sessions.length === 0 ? (
116 <p style="color:#6b7280;margin:0;font-size:14px">None yet.</p>
117 ) : (
118 <ul style="list-style:none;padding:0;margin:0">
119 {sessions.map((s) => (
120 <li style="padding:10px 0;border-top:1px solid #1f2937;display:flex;justify-content:space-between;align-items:center">
121 <a
122 href={`/${g.ownerName}/${g.repoName}/claude/${s.id}`}
123 style="color:#7aa2f7;text-decoration:none;font-weight:600"
124 >
125 {s.title}
126 </a>
127 <span style="color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
128 {s.branch} · {s.status}
129 </span>
130 </li>
131 ))}
132 </ul>
133 )}
134 </div>
135 </main>
136 </Layout>
137 );
138});
139
140// ─── POST /:owner/:repo/claude ──────────────────────────────────────────────
141
142claudeWeb.post("/:owner/:repo/claude", async (c) => {
143 const g = await gate(c);
144 if (g instanceof Response) return g;
145 const form = await c.req.parseBody();
146 const title = String(form.title || "").trim() || "New session";
147 const branch = String(form.branch || "").trim() || "main";
148 const session = await createSession({
149 repositoryId: g.repoId,
150 ownerUserId: g.userId,
151 title,
152 branch,
153 });
154 return c.redirect(`/${g.ownerName}/${g.repoName}/claude/${session.id}`);
155});
156
157// ─── GET /:owner/:repo/claude/:sessionId ────────────────────────────────────
158
159claudeWeb.get("/:owner/:repo/claude/:sessionId", async (c) => {
160 const g = await gate(c);
161 if (g instanceof Response) return g;
162 const user = c.get("user")!;
163 const sessionId = c.req.param("sessionId");
164 const session = await getSession(sessionId, g.userId);
165 if (!session || session.repositoryId !== g.repoId) return c.notFound();
166
167 const messages = await listMessages(sessionId);
168
169 // Inline SSE client. ~30 lines of vanilla JS — keeps the bundle empty.
170 const clientJs = `
171 (function() {
172 var f = document.getElementById('cw-composer');
173 if (!f) return;
174 f.addEventListener('submit', function(ev) {
175 ev.preventDefault();
176 var input = document.getElementById('cw-prompt');
177 var prompt = (input.value || '').trim();
178 if (!prompt) return;
179 input.value = '';
180 var list = document.getElementById('cw-messages');
181 var u = document.createElement('div');
182 u.className = 'cw-msg cw-msg-user';
183 u.textContent = prompt;
184 list.appendChild(u);
185 var a = document.createElement('pre');
186 a.className = 'cw-msg cw-msg-assistant';
187 a.textContent = '';
188 list.appendChild(a);
189 var url = ${JSON.stringify(`/${g.ownerName}/${g.repoName}/claude/${sessionId}/stream`)} + '?prompt=' + encodeURIComponent(prompt);
190 var es = new EventSource(url);
191 es.addEventListener('chunk', function(e) {
192 a.textContent += e.data;
193 window.scrollTo(0, document.body.scrollHeight);
194 });
195 es.addEventListener('done', function() {
196 es.close();
197 });
198 es.addEventListener('error', function() {
199 a.textContent += '\\n[stream error — reload to retry]';
200 es.close();
201 });
202 });
203 })();
204 `;
205
206 const styleCss = `
207 .cw-msg { padding:10px 12px;border-radius:8px;margin:8px 0;white-space:pre-wrap;word-wrap:break-word;font-family:ui-monospace,monospace;font-size:14px;line-height:1.5; }
208 .cw-msg-user { background:#172033;color:#e5e7eb;border:1px solid #1f6feb55; }
209 .cw-msg-assistant { background:#0e1117;color:#cbd5e1;border:1px solid #1f2937; }
210 .cw-msg-system { background:#2a1f10;color:#e1c47f;border:1px solid #5a4a1f;font-size:13px; }
211 `;
212
213 return c.html(
214 <Layout title={`${session.title} — Claude`} user={user}>
215 <main style={wrap}>
216 <p style="margin:0 0 6px">
217 <a href={`/${g.ownerName}/${g.repoName}/claude`} style="color:#9ca3af;text-decoration:none">
218 ← Claude sessions
219 </a>
220 </p>
221 <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
222 <h1 style="margin:0;font-size:20px">{session.title}</h1>
223 <span style="color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
224 {session.branch} · {session.status}
225 </span>
226 </div>
227 <style dangerouslySetInnerHTML={{ __html: styleCss }} />
228
229 <div id="cw-messages" style="margin-bottom:14px">
230 {messages.length === 0 ? (
231 <p style="color:#6b7280;font-size:14px">No turns yet. Ask Claude something below.</p>
232 ) : (
233 messages.map((m) => (
234 <div
235 class={"cw-msg cw-msg-" + m.role}
236 data-role={m.role}
237 >
238 {m.body}
239 </div>
240 ))
241 )}
242 </div>
243
244 <form id="cw-composer" style={card}>
245 <textarea
246 id="cw-prompt"
247 name="prompt"
248 placeholder="Ask Claude..."
249 rows={3}
250 style={inputStyle}
251 />
252 <div style="margin-top:8px;display:flex;justify-content:space-between;align-items:center">
253 <span style="color:#6b7280;font-size:12px">
254 Streams over SSE. Long turns cap at 5 minutes.
255 </span>
256 <button type="submit" style={btn}>Send</button>
257 </div>
258 </form>
259
260 <form
261 method="post"
262 action={`/${g.ownerName}/${g.repoName}/claude/${session.id}/delete`}
263 style="text-align:right;margin-top:12px"
264 onsubmit="return confirm('Delete this session and its transcript?')"
265 >
266 <button type="submit" style="background:#a02020;border:0;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px">
267 Delete session
268 </button>
269 </form>
270
271 <script dangerouslySetInnerHTML={{ __html: clientJs }} />
272 </main>
273 </Layout>
274 );
275});
276
277// ─── GET /:owner/:repo/claude/:sessionId/stream ─────────────────────────────
278
279claudeWeb.get("/:owner/:repo/claude/:sessionId/stream", async (c) => {
280 const g = await gate(c);
281 if (g instanceof Response) return g;
282 const sessionId = c.req.param("sessionId");
283 const session = await getSession(sessionId, g.userId);
284 if (!session || session.repositoryId !== g.repoId) return c.notFound();
285 const prompt = (c.req.query("prompt") || "").slice(0, 16_000);
286 if (!prompt.trim()) {
287 return c.text("missing prompt", 400);
288 }
289
290 // Persist the user turn *before* we start the stream so a network blip
291 // doesn't lose the question.
292 await appendMessage({ sessionId, role: "user", body: prompt });
293 await touchSession({ sessionId, status: "running" });
294
295 // Lazily clone the workdir on first turn.
296 const workdir = await ensureWorkdir(session, g.ownerName, g.repoName);
297 if (!workdir.ok) {
298 await appendMessage({
299 sessionId,
300 role: "system",
301 body: `workdir error: ${workdir.error}`,
302 });
303 await touchSession({ sessionId, status: "failed" });
304 return c.text(`workdir: ${workdir.error}`, 500);
305 }
306
307 const stream = new ReadableStream<Uint8Array>({
308 async start(controller) {
309 const enc = new TextEncoder();
310 let assistantBody = "";
311 let finalExit = 0;
312 let finalDuration = 0;
313 let finalClaudeId: string | undefined;
314
315 function send(event: string, data: string) {
316 controller.enqueue(enc.encode(`event: ${event}\n`));
317 // Split data on newlines so we never break the SSE wire format.
318 for (const line of data.split("\n")) {
319 controller.enqueue(enc.encode(`data: ${line}\n`));
320 }
321 controller.enqueue(enc.encode(`\n`));
322 }
323
324 try {
325 for await (const ev of runTurn({
326 session,
327 ownerName: g.ownerName,
328 repoName: g.repoName,
329 prompt,
330 })) {
331 if (ev.chunk) {
332 assistantBody += ev.chunk;
333 send("chunk", ev.chunk);
334 }
335 if (ev.done) {
336 finalExit = ev.done.exitCode;
337 finalDuration = ev.done.durationMs;
338 finalClaudeId = ev.done.claudeSessionId;
339 if (ev.done.stderr && ev.done.exitCode !== 0) {
340 assistantBody += `\n[stderr] ${ev.done.stderr}`;
341 send("chunk", `\n[stderr] ${ev.done.stderr}`);
342 }
343 send("done", String(ev.done.exitCode));
344 }
345 }
346 } catch (err) {
347 const msg = err instanceof Error ? err.message : String(err);
348 assistantBody += `\n[exception] ${msg}`;
349 send("error", msg);
350 } finally {
351 try {
352 await appendMessage({
353 sessionId,
354 role: "assistant",
355 body: assistantBody,
356 exitCode: finalExit,
357 durationMs: finalDuration,
358 });
359 await touchSession({
360 sessionId,
361 claudeSessionId: finalClaudeId,
362 status: finalExit === 0 ? "ready" : "failed",
363 });
364 } catch {
365 /* persistence errors don't break the response */
366 }
367 controller.close();
368 }
369 },
370 });
371
372 return new Response(stream, {
373 headers: {
374 "Content-Type": "text/event-stream; charset=utf-8",
375 "Cache-Control": "no-cache, no-transform",
376 "Connection": "keep-alive",
377 "X-Accel-Buffering": "no",
378 },
379 });
380});
381
382// ─── POST /:owner/:repo/claude/:sessionId/delete ────────────────────────────
383
384claudeWeb.post("/:owner/:repo/claude/:sessionId/delete", async (c) => {
385 const g = await gate(c);
386 if (g instanceof Response) return g;
387 const sessionId = c.req.param("sessionId");
388 const session = await getSession(sessionId, g.userId);
389 if (!session || session.repositoryId !== g.repoId) return c.notFound();
390 await deleteSession(sessionId);
391 return c.redirect(`/${g.ownerName}/${g.repoName}/claude`);
392});
393
394export default claudeWeb;
0395