npm package (octomux) for orchestrating autonomous Claude Code agents from a web dashboard.
Single binary: octomux <command>. Data stored at ~/.octomux/ in production,
./data/ in development (NODE_ENV !== 'production').
- Frontend: Vite + React 19 + Tailwind CSS 4 + shadcn/ui + React Router 7
- Backend: Express 5 + better-sqlite3 (WAL mode) + node-pty + ws
- Terminal: xterm.js (bidirectional) → node-pty →
tmux attach - Isolation: git worktrees per task, tmux sessions per task, tmux windows per agent
- IDs: nanoid(12)
- Runtime: bun (package manager + script runner), tsx (dev server)
- Binary: single
octomuxcommand (bin/octomux.js) —startlaunches dashboard, all other subcommands are CLI operations
bun run dev— starts Express (7777) + Vite concurrentlybun run test— vitest run (unit + component tests)bun run test:watch— vitest in watch modebun run test:e2e— Playwright E2E tests (auto-starts servers)bun run test:e2e:ui— Playwright interactive UI modebun run lint/bun run lint:fix— ESLint 9 flat configbun run format/bun run format:check— Prettierbun run typecheck— tsc --noEmitbun run build— Vite build + tsc server
server/— Express backend (API, terminal streaming, task lifecycle, DB)api.ts— REST routes mounted on Express appapp.ts— extractedcreateApp()for testabilitytask-runner.ts— worktree + tmux + harness lifecycle (closeTask, deleteTask)db.ts— SQLite singleton withgetDb()/setDb()/initDb()logger.ts— pino root +childLogger('<module>')helpertypes.ts— shared types (Task, Agent, TaskStatus, AgentStatus)harnesses/— pluggable harness implementations (Claude Code today; Cursor planned). EachHarnessexportsid,displayName,sessionIdMode, command builders,installHooks,syncAgents,resolveFlags,validateSettings. Spec atspec/harness-abstraction.md; step plan atplans/2026-05-08-harness-abstraction-step-1.md.hook-base-url.ts—hookBaseUrl()returnshttp://127.0.0.1:<port>for harness callbacks.teams.ts— team feature:parseTeamConfig,validateTeamConfig,runTeam,upsertTeamSchedule,listTeamSchedules,isCronDue,pollTeamSchedules. Config lives in<repo>/.octomux/team.yaml.
src/— React SPA (pages, components, lib/api.ts)cli/— CLI tool for task management (create-task, list-tasks, get-task, close-task)e2e/— Playwright E2E tests
DB migrations are forward-only. Back up ~/.octomux/octomux.sqlite (prod) or
./data/octomux.sqlite (dev) before upgrading across the harness-abstraction
migration (renames agents.claude_session_id → harness_session_id, adds
tasks.harness_id / agents.harness_id / agents.hook_token, relaxes
permission_prompts.session_id to nullable).
- All server-side logs go through
server/logger.ts(pino). Useconst logger = childLogger('<module>');at the top of eachserver/file and emit structured events —logger.info({ task_id, operation, ... }, 'message'). Never useconsole.*inserver/. - Every task/agent lifecycle log line must include
task_id(andagent_idwhere relevant) so grep can reconstruct a timeline:grep '"task_id":"<id>"' ~/.octomux/logs/octomux.log. - Output: dev = pretty stdout + rotated JSON at
./data/logs/octomux.log, prod = rotated JSON at~/.octomux/logs/octomux.log, test = silent. - Rotation: daily or 10MB, 7 files kept (pino-roll).
- Default level:
infoin prod,debugin dev; override withLOG_LEVEL. - Tests assert log output by piping pino into a buffer via
setLogger(pino({level:'trace'}, stream)).
draft → setting_up → running → closed/error
Error at any point → error state with message in task.error
Per task: git worktree at <repo>/.worktrees/<id>, tmux session octomux-agent-<id>,
branch agents/<id>. Each agent = tmux window within the session.
- close = stop agents + kill tmux session. Preserves worktree and branch (for resume).
- delete = kill tmux session + remove worktree + delete branch + delete DB rows. Full cleanup.
Reusable crews of agents that run on a schedule against any repo. Config-as-code: definitions
live in <repo>/.octomux/team.yaml; octomux reads at run time, never stores in DB.
octomux team run <name> [-r <repo-path>] # fire immediately from .octomux/team.yaml
octomux team schedule <name> --cron <expr> [-r <path>] # upsert cron schedule
octomux team list # list configured schedules
name: my-team # must match name passed to CLI
base_branch: main # optional; default main
schedule: '0 7 * * 1-5' # optional; only used as reference — use `team schedule` to activate
notify_command: "slack-notify.sh '#alerts'" # optional; passed to Lead
journal_dir: desk/journal # optional; default desk/journal
incidents_dir: desk/incidents # optional; default desk/incidents
roster:
- role: lead # REQUIRED; exactly one lead
skeleton: desk-lead # filename under agents/ (no .md)
model: claude-opus-4-8
overlay: .octomux/overlays/lead.md # optional repo-specific override
- role: researcher
skeleton: researcher
model: claude-sonnet-4-6
- role: risk-ops
skeleton: risk-ops
model: claude-sonnet-4-6Skeletons live in the target repo at <repo>/.octomux/agents/<name>.md. octomux
ships no built-in skeletons — each consuming repo owns its own. A Lead receives the full
roster in its kick-off prompt and spawns workers via octomux create-task --model <model> ....
tasks.model TEXT column added in Phase 0. Propagated through:
POST /api/tasksbody:{ model: "claude-opus-4-8" }→ stored in DBPOST /api/tasks/:id/agentsbody:{ model: ... }→ stored on agent launchoctomux create-task --model <id>andoctomux add-agent --model <id>- Harness:
applyModel(flags, model)strips any existing--modelthen appends the per-task one
-- operational state only; definitions stay in team.yaml
team_schedules (name PK, repo_path, config_path, cron, enabled, last_run_at, created_at, updated_at)
team_runs (id PK, team, lead_task_id → tasks.id, started_at, status)startPolling() sets a 60 s interval calling pollTeamSchedules(). For each enabled schedule:
- evaluate 5-field cron (
* * * * *) against current UTC minute viacroner(isCronDue) - skip if a
team_runsrow withstatus='running'already exists (idempotent) - call
runTeam(), insertteam_runsrow, updatelast_run_at
Cron expressions use croner (ranges, steps, lists, named weekdays — e.g. */15, 1-5, mon-fri). Schedules are evaluated in UTC.
- vitest with
NODE_ENV=test(set in vitest.config.ts) - Table-driven tests using
it.each()— prefer over individual test cases - Shared test harness:
server/test-helpers.ts(DEFAULTS fixtures, insert/get helpers, shell mock assertion helpers viafindExecCall/countExecCalls) - DB tests use in-memory SQLite via
createTestDb()→ callssetDb()for isolation - task-runner tests mock
child_process(execFile, spawn) andfs(existsSync, mkdirSync, copyFileSync) - API tests use supertest against
createApp() CLAUDE_INIT_DELAYis 0 in test env to avoid 3s sleepsOCTOMUX_AI_TASK_NAMING=1(ortrue) — optional: on task create withinitial_prompt, run Claude CLI to polish omitted title/description; off by default so POST/api/tasksreturns immediately without that subprocess- E2E: Playwright tests in
e2e/, config inplaywright.config.ts - E2E:
webServerconfig auto-starts Express + Vite, reuses running servers in dev - E2E: helpers in
e2e/helpers.ts—createTaskViaAPI,waitForStatus,deleteAllTasks,fillCreateDialog - E2E: base-ui Dialog dismisses on Playwright
fill()— useclick({force:true})+pressSequentiallyinstead - E2E: terminal text leaks into locators — use
getByRoleor.filter()to avoid strict mode violations
- Prettier: single quotes, trailing commas, 100 char width, semicolons
- ESLint:
@typescript-eslint/no-explicit-anyis warn (off in test files) - Conventional commits enforced:
feat(scope): message,fix(scope): message, etc. - Kebab-case scopes, 100 char header max
- Use template literals for SQL with
datetime('now')— single quotes inside backticks
- SQLite
datetime('now')needs single-quoted'now'— use template literals, not regular strings fsmock for task-runner needsdefault: mockedin vi.mock return (default import)- Express 5 uses
req.paramsdifferently — useas Record<string, string>if needed - better-sqlite3 is synchronous — no await needed for DB calls
- node-pty
spawn-helpermay lack +x after install — postinstall script fixes this - tmux
base-indexvaries per user — always query actual window index viadisplay-message/list-windows, never hardcode 0 - shadcn/ui uses
@base-ui/react— userender={<Button />}prop, notasChild - vitest projects: put
globals: truein each project config individually, not just top-level - Frontend test helpers in
src/test-helpers.tsx:makeTask(),renderWithRouter(),mockApi() - poller tests: use
findCallback(...args)to find callback in promisified execFile mocks - logger path resolution is lazy — tests that stub
os/fsmust not expect the log dir to exist at module-load time (pino is silent in NODE_ENV=test anyway) task_external_refs.metadatais a nullable JSON text column — always parse withJSON.parse(row.metadata ?? 'null')server-side, never expose the raw string. The hook dispatcher'sloadTaskExternalRefs(taskId)helper already does this for provider envelopes; route handlers must parse on read too.- Linear integration uses
@linear/sdkviaserver/integrations/linear/graphql.ts(createLinearClient/invokeLinear). Pass the bare API key — the SDK sends it without aBearerprefix. SDK errors are wrapped asLinearApiError.
When working on this codebase via Claude Code, parallel Agent({ isolation: "worktree" })
dispatches have proved unreliable: agents leaked back into the parent worktree and
clobbered each other's commits during the wave-2 implementation. Until that's
verified end-to-end, default to sequential dispatch — one sub-agent at a time, or
a single agent for an entire wave.
If you must run agents in parallel:
- After dispatch, capture each agent's actual worktree path with
git worktree list. - Pass the absolute path explicitly in the prompt and tell the agent to
cdthere before any file or git operation. - Verify both agents are on distinct branches before they start committing.
This is unrelated to octomux's own runtime tasks (worktree + tmux + agents) — see "Task Lifecycle" above for that. The note here is purely about Claude Code's sub-agent harness.