SLOPSHOPPER

phantra-ops

The phone-length asks as slash commands (/ship, /merge, /wrap, /handoff), a local-servers status line with /servers [stop], and a session-start check for the…

newguardcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · phantra-ops
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ phantra-ops │ ⏺ Read(src/auth.ts) │ phantra-ops: 2 uncommitted paths (another │ ⎿ Read 6 lines │ session's?) · stale .git/index.lock -- rm │ ⏺ Update(src/auth.ts) │ it before any git command │ ⎿ 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 › /servers ⎿ phantra-ops: Up: api:8000, web:3000, ollama. `/servers stop` stops them. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ phantra-ops: local: api:8000 · web:3000 · ollama
README

Phantra

An AI production studio. Bring an idea and your own photos; get stills and video made on current image and video models, held to those photos, and pay in credits only for what you approve. Live at phantra.ai. (Named Zero Page until 2026-10-13.)

What it does

  • Create. One chat box. The assistant talks an idea through, proposes a plan, and makes a still or writes a scene when asked. Every step that costs credits waits on a card that shows the price.
  • Scenes held to your photos. A scene is written as timed shots and rendered shot by shot against the photos of your characters, products and places. A scene with no photographs does not render.
  • Projects. A brief, a look and a memory per piece of work.
  • Library and effects. Everything you made, with image edits, camera moves, upscaling, sound, reframing, draft-then-finish and real downloads.
  • Editor. A timeline over your rendered clips, with an agent that proposes edits you keep or undo, and export to MP4, audio, a still or OpenTimelineIO.
  • Connector. An MCP server so Claude or ChatGPT can work in your workspace: https://api.phantra.ai/mcp and https://api.phantra.ai/mcp/claude. See phantra.ai/connector.

How it is built

PartWhere
Site and studioNext.js in web/, on Vercel
APIFastAPI in app/ over the library in src/, on Fly
Data and sign-inPostgres and Auth on Supabase
MediaCloudflare R2, served at media.phantra.ai
Video and image modelsfal.ai (Kling, Veo, Seedance, LTX, Wan, FLUX and others) and Gemini
BillingStripe plans that grant credits; a ledger hold before every provider call

Running it

venv/bin/pip install -r requirements.txt
venv/bin/pip install -e .
venv/bin/uvicorn app.main:app --reload      # the API on 127.0.0.1:8000
cd web && npm install && npm run dev        # the studio on localhost:3000

.env.example and web/.env.example list the settings. START_SERVER.md covers the local server and the MCP mount; CLAUDE.md is the working guide to the code, commands and conventions; docs/ holds the task files, the billing design and the runbook.

Checks

venv/bin/python -m pytest tests/ -q
venv/bin/ruff check .
cd web && node --test tests/*.test.mjs && npm run typecheck && npm run build

The test suite blocks all network access, so a test cannot make a billed call.

The README this replaced is kept at docs/retired/README-before-phantra.md.

Source 1 files
hooks/register.ts 102 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// What the transcripts kept asking by hand: "stop the local servers", "is ollama
4// still running", and the session-start traps the handoffs warn about (a stale
5// .git/index.lock from a Cowork VM, another session's uncommitted work, stale
6// worktrees under .claude/worktrees/).
7
8type Server = { label: string; port: string; kill: string[][] }
9
10const SERVERS: readonly Server[] = [
11  { label: 'api:8000', port: '8000', kill: [['pkill', '-f', 'uvicorn app.main']] },
12  { label: 'web:3000', port: '3000', kill: [['pkill', '-f', 'next dev'], ['pkill', '-f', 'next-server']] },
13  { label: 'ollama', port: '11434', kill: [['pkill', '-f', 'ollama serve'], ['pkill', '-x', 'Ollama']] },
14]
15
16// A Bash command that may have started or stopped one of them.
17const TOUCHES = /uvicorn|next dev|npm run dev|run dev|ollama|pkill|\bkill\b|studio\.command|dev\.command|serve\.sh|start-studio/
18
19async function run($: EngineInterface, argv: string[], cwd?: string) {
20  try {
21    return await $.process.run(argv, { timeoutMs: 10_000, ...(cwd ? { cwd } : {}) })
22  } catch {
23    return null
24  }
25}
26
27async function listening($: EngineInterface, port: string): Promise<boolean | null> {
28  const r = await run($, ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN'])
29  if (r === null) return null // no lsof here (a cloud container): unknown, say nothing
30  return r.exitCode === 0 && r.stdout.trim() !== ''
31}
32
33async function up($: EngineInterface): Promise<string[] | null> {
34  const found: string[] = []
35  for (const s of SERVERS) {
36    const on = await listening($, s.port)
37    if (on === null) return null
38    if (on) found.push(s.label)
39  }
40  return found
41}
42
43async function refresh($: EngineInterface) {
44  const found = await up($)
45  if (found === null) return
46  $.ui.status(found.length ? `local: ${found.join(' · ')}` : undefined)
47}
48
49async function checkRepo($: EngineInterface, cwd: string) {
50  const notes: string[] = []
51  const status = await run($, ['git', 'status', '--porcelain'], cwd)
52  if (status && status.exitCode === 0) {
53    const n = status.stdout.split('\n').filter(l => l.trim()).length
54    if (n > 0) notes.push(`${n} uncommitted path${n === 1 ? '' : 's'} (another session's?)`)
55  }
56  const lock = await run($, ['test', '-e', '.git/index.lock'], cwd)
57  if (lock && lock.exitCode === 0) notes.push('stale .git/index.lock -- rm it before any git command')
58  const trees = await run($, ['git', 'worktree', 'list', '--porcelain'], cwd)
59  if (trees && trees.exitCode === 0) {
60    const stale = trees.stdout.split('\n').filter(l => l.startsWith('worktree ') && l.includes('.claude/worktrees/')).length
61    if (stale > 0) notes.push(`${stale} worktree${stale === 1 ? '' : 's'} under .claude/worktrees/`)
62  }
63  if (notes.length) $.ui.toast(`phantra-ops: ${notes.join(' · ')}`, { timeoutMs: 12_000 })
64}
65
66export const register: Register = on => {
67  on('session.start', async ($, e, next) => {
68    const started = await next(e)
69    await $.command.register({
70      name: 'servers',
71      description: 'Local dev servers on :8000 / :3000 / ollama; `/servers stop` stops them',
72      argumentHint: '[stop]',
73    })
74    if (e.isInteractive) {
75      void checkRepo($, e.cwd)
76      void refresh($)
77    }
78    return started
79  })
80
81  on('command.run', { command: 'servers' }, async ($, e) => {
82    if (e.args.trim() === 'stop') {
83      const before = await up($)
84      for (const s of SERVERS) for (const argv of s.kill) await run($, argv)
85      await refresh($)
86      const after = await up($)
87      const stopped = (before ?? []).filter(l => !(after ?? []).includes(l))
88      return { text: stopped.length ? `Stopped: ${stopped.join(', ')}.` : 'Nothing was running.' }
89    }
90    const found = await up($)
91    if (found === null) return { text: 'phantra-ops: cannot check ports here (no lsof).' }
92    await refresh($)
93    return { text: found.length ? `Up: ${found.join(', ')}. \`/servers stop\` stops them.` : 'No local servers running.' }
94  })
95
96  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
97    const ran = await next(e)
98    if (TOUCHES.test(e.command)) void refresh($)
99    return ran
100  })
101}
102