SLOPSHOPPER

web-ide

Web IDE: the ticket of the worktree above the prompt, and the files Claude changes opened in the IDE with one key

newpanebandguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-09DrSmithFr/web-ide/claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · web-ide
│ ┃ IDE ✕ › fix the failing auth test and add an audit log call │ ┃ This folder is not a project of the IDE. │ ┃ ↗ cache.ts ⏺ Read(src/auth.ts) │ ┃ ↗ audit.ts ⎿ Read 6 lines │ ┃ ↗ auth.ts ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /webide │ ⎿ web-ide: This folder is not a project of the IDE. │ │ IDE ↗ cache.ts ↗ audit.ts ↗ auth.ts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
IDE ↗ cache.ts ↗ audit.ts ↗ auth.ts
Pane · IDE
This folder is not a project of the IDE. ↗ cache.ts ↗ audit.ts ↗ auth.ts
README

Web IDE

Version française

A self-hosted IDE in your browser, with an AI agent that runs on your models (llama.cpp, Ollama) and a kanban that takes each change from an idea to a merged branch.

The model develops a ticket in its own git worktree: it reads the code, edits, runs the tests and commits

  • One binary, local first. A small Go program, the pod, serves the interface and does the work on your machine: files, git, terminals, language servers, databases, model calls. No Electron, no cloud, no account.
  • The same session everywhere. Open it from another browser or device (over Tailscale) and find your tabs, splits and terminals as you left them.
  • A development cycle with the agent. Briefing → plan → development in a worktree → test → merge, each step with its conversation, each decision yours.

From an idea to a merged branch

1. Briefing: describe the need; the model reads the code, asks its questions, then writes the ticket. <br><br> Briefing2. Plan: the model writes the implementation plan and goals you can check. <br><br> Plan
3. Development: a branch and a worktree per ticket, in its own window; the model codes, tests, commits. <br><br> Development4. Test and merge: how to test, the goals, the diff; feedback goes back to the model; merge when it is right. <br><br> Review

The full walk-through is in the user guide.

And a real editor

The editor with completion from gopls, and a terminal

  • Editor: fast on 100,000-line files, splits, multiple carets, folding, Search Everywhere, Recent Files, clipboard history, themes (this is High contrast), QWERTY and AZERTY shortcuts, full keyboard navigation.
  • Language servers: definition, references, completion, rename, formatting, diagnostics (gopls, typescript-language-server, pyright, intelephense…).
  • Git: changes, staging, commits, branches, history graph, side-by-side diffs, worktrees.
  • Terminals, databases (SQLite, PostgreSQL, Redis), Docker (Compose, containers, logs) and SSH tunnels.
  • Local and SSH projects, with your local keys.
  • Safe with other tools: changes made to open files by another program are merged into your buffer.
  • Assistant: Build, Plan and Briefing modes, CLAUDE.md / AGENTS.md and skills, Mermaid diagrams, image / PDF / audio attachments, local dictation (Whisper in the browser), doodles.
  • Interface in English and French.
The kanbanThe Git panel

Install

Linux, as a service started at boot (details):

curl -fsSL https://raw.githubusercontent.com/DrSmithFr/web-ide/main/scripts/install.sh | sh
# from your other devices, over HTTPS on your Tailscale network:
curl -fsSL https://raw.githubusercontent.com/DrSmithFr/web-ide/main/scripts/install.sh | sh -s -- --tailscale

It prints the address to open once, with the pairing token. On macOS, download the archive of the latest release and run ./web-ide-pod.

From the sources (Go 1.27+, Node.js 20.19+):

git clone https://github.com/DrSmithFr/web-ide.git && cd web-ide
make build && ./bin/web-ide-pod

Optional: git, language servers in the PATH, a llama.cpp or Ollama server for the assistant.

Security

The pod has the rights of your user: it reads and writes files and runs commands for the page. It only answers the local machine, every request needs the pairing token, and the agent should only be connected to model servers you trust. See SECURITY.md.

Contributing

CONTRIBUTING.md to get started, docs/architecture.md for the code, docs/spec.md for the intended behaviour, CHANGELOG.md for the releases.

License

MIT

Source 2 files
hooks/register.tsx 185 lines
1// Web IDE in Claude Code, above the prompt: links to the project Claude works in and to
2// the ticket of its worktree (at the public address of the IDE, publicUrl of the pod),
3// and the files Claude changes as buttons opening them in the IDE windows (the /open
4// endpoint of the pod, with its token). The band above the prompt is drawn by the
5// terminal and the desktop app only: the mobile app gets the same links in a pane, opened
6// when it joins the session and by /webide. /webide <file[:line]> opens any file (/ide
7// is a command of Claude Code).
8//
9// The pod: WEBIDE_URL (default http://127.0.0.1:4433), its data folder WEBIDE_DATA
10// (default ~/.web-ide), where the token is.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Ide, Ticket } from '../types'
15
16const ide = atom({ plugin: 'web-ide', key: 'ide' } as const, null)
17const files = atom({ plugin: 'web-ide', key: 'files' } as const, [])
18
19const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
20const MAX_FILES = 4
21const PANE = 'web-ide'
22// Tools moving the session to another folder: the project and the ticket change.
23const MOVES = new Set(['EnterWorktree', 'ExitWorktree'])
24
25type Pod = { url: string; token: string }
26
27/** "# Ticket #12 · Export\nStatus: In progress · …\n…\nTicket in the IDE: <url>" → the ticket. */
28export function parseTicket(markdown: string): Ticket | null {
29  const [, id, title = '', status = ''] = /^# Ticket #(\d+) · (.*)\nStatus: ([^·\n]+)/.exec(markdown) ?? []
30  const url = /^Ticket in the IDE: (\S+)$/m.exec(markdown)?.[1]
31  return id && url ? { id: Number(id), title: title.trim(), status: status.trim(), url } : null
32}
33
34/** The page of the project in an answer of kanban_list or kanban_get. */
35export function parseProject(text: string): string | null {
36  return /^Project in the IDE: (\S+)$/m.exec(text)?.[1] ?? null
37}
38
39/** "src/a.go:12" → the absolute path and the line (0 without one). */
40export function parseTarget(arg: string, cwd: string): { path: string; line: number } {
41  const [, file = '', line] = /^(.*?)(?::(\d+))?$/.exec(arg.trim()) ?? []
42  const path = file.startsWith('/') ? file : `${cwd.replace(/\/$/, '')}/${file.replace(/^\.\//, '')}`
43  return { path, line: line ? Number(line) : 0 }
44}
45
46/** The files list after a change: the newest first, without duplicates. */
47export function touched(list: string[], path: string): string[] {
48  return [path, ...list.filter((p) => p !== path)].slice(0, MAX_FILES)
49}
50
51const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1)
52
53let pod: Pod | null = null
54
55async function connect($: EngineInterface): Promise<Pod | null> {
56  if (pod) return pod
57  const home = (await $.env.get('HOME')) ?? ''
58  const data = (await $.env.get('WEBIDE_DATA')) ?? `${home}/.web-ide`
59  const url = (await $.env.get('WEBIDE_URL')) ?? 'http://127.0.0.1:4433'
60  try {
61    pod = { url: url.replace(/\/$/, ''), token: (await $.fs.read(`${data}/token`)).trim() }
62  } catch {
63    return null // no pod on this machine
64  }
65  return pod
66}
67
68/** Calls a tool of the MCP endpoint of the pod: its text, null on an error. */
69async function tool($: EngineInterface, p: Pod, name: string, args: Record<string, unknown>): Promise<string | null> {
70  const res = await $.http.fetch(`${p.url}/mcp`, {
71    method: 'POST',
72    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${p.token}` },
73    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
74  })
75  const r = JSON.parse(res.text)?.result
76  return r && !r.isError ? (r.content?.[0]?.text ?? '') : null
77}
78
79/** The project Claude works in and the ticket of its worktree. */
80async function refresh($: EngineInterface) {
81  const p = await connect($)
82  if (!p) return
83  const cwd = await $.session.cwd()
84  try {
85    // In a worktree, kanban_get answers its ticket; elsewhere it fails and kanban_list
86    // still gives the project (or fails too: not a project of the IDE).
87    const text = (await tool($, p, 'kanban_get', { cwd })) ?? (await tool($, p, 'kanban_list', { cwd }))
88    const project = text ? parseProject(text) : null
89    const next: Ide | null = project ? { project, ticket: parseTicket(text ?? '') } : null
90    await update($, ide, () => next)
91  } catch {
92    await update($, ide, () => null) // the pod is not running
93  }
94}
95
96/** Opens a file in the windows of its project; answers what happened. */
97async function open($: EngineInterface, path: string, line = 0): Promise<string> {
98  const p = await connect($)
99  if (!p) return 'Web IDE: no pod token found (~/.web-ide/token).'
100  try {
101    const res = await $.http.fetch(`${p.url}/open?path=${encodeURIComponent(path)}&line=${line}`, {
102      method: 'POST',
103      headers: { Authorization: `Bearer ${p.token}` },
104    })
105    if (!res.ok) return `Web IDE: ${res.text.trim()}`
106    const r = JSON.parse(res.text)
107    return r.opened ? `Opened in the IDE: ${path}` : `No IDE window on this project: ${r.url}`
108  } catch {
109    return `Web IDE: the pod does not answer at ${p.url}.`
110  }
111}
112
113export const register: Register = (on) => {
114  on('session.start', async ($, e, next) => {
115    await $.command.register({ name: 'webide', description: 'Links to the Web IDE (project, ticket, files), or open a file in it', argumentHint: '[file[:line]]' })
116    void refresh($)
117    return next(e)
118  })
119
120  // The mobile app draws no band above the prompt: the links go in a pane there.
121  on('session.attach', { surface: 'mobile' }, async ($, e, next) => {
122    void refresh($)
123    void $.ui.open({ id: PANE, title: 'IDE' })
124    return next(e)
125  })
126
127  on('turn.complete', async ($, e, next) => {
128    void refresh($)
129    return next(e)
130  })
131
132  on('tool.call', async ($, e, next) => {
133    const ran = await next(e)
134    const path = (e as { file_path?: string }).file_path ?? (e as { notebook_path?: string }).notebook_path
135    if (EDITS.has(e.tool) && path && ran.deny === undefined) await update($, files, (list) => touched(list, path))
136    if (MOVES.has(e.tool) && ran.deny === undefined) void refresh($)
137    return ran
138  })
139
140  on('command.run', { command: 'webide' }, async ($, e) => {
141    if (e.args.trim()) {
142      const t = parseTarget(e.args, await $.session.cwd())
143      return { text: await open($, t.path, t.line) }
144    }
145    await refresh($)
146    await $.ui.open({ id: PANE, title: 'IDE' })
147    const here = await read($, ide)
148    if (!here) return { text: 'This folder is not a project of the IDE.' }
149    return { text: [`Project: ${here.project}`, ...(here.ticket ? [`Ticket #${here.ticket.id}: ${here.ticket.url}`] : [])].join('\n') }
150  })
151
152  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
153    const here = await read($, ide)
154    const list = await read($, files)
155    const { Box, Button, Link, Text } = $.ui.resolve(e)
156    const tk = here?.ticket
157    return (
158      <Box flexDirection="column">
159        {here ? <Link key="project" href={here.project} label="Project in the IDE" /> : <Text dimColor>This folder is not a project of the IDE.</Text>}
160        {tk && <Link key="ticket" href={tk.url} label={`#${tk.id} ${tk.title} · ${tk.status}`} />}
161        {list.map((path) => (
162          <Button key={path} label={`↗ ${basename(path)}`} plain onPress={() => void open($, path).then((text) => $.ui.toast(text))} />
163        ))}
164      </Box>
165    )
166  })
167
168  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169    const here = await read($, ide)
170    const list = await read($, files)
171    if (e.props.hasSurvey || (!here && list.length === 0)) return next(e)
172    const { Box, Button, Link, Text } = $.ui.resolve(e)
173    const tk = here?.ticket
174    return (
175      <Box flexDirection="row" gap={1}>
176        {here ? <Link key="project" href={here.project} label="IDE" /> : <Text dimColor>IDE</Text>}
177        {tk && <Link key="ticket" href={tk.url} label={`#${tk.id} ${tk.title} · ${tk.status}`} />}
178        {list.map((path) => (
179          <Button key={path} label={`↗ ${basename(path)}`} plain dimColor onPress={() => void open($, path).then((text) => $.ui.toast(text))} />
180        ))}
181      </Box>
182    )
183  })
184}
185
types/index.d.ts 11 lines
1export type Ticket = { id: number; title: string; status: string; url: string }
2
3/** The IDE project Claude works in: its page, and the ticket of its worktree. */
4export type Ide = { project: string; ticket: Ticket | null }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'web-ide': { ide: Ide | null; files: string[] }
9  }
10}
11