diff --git a/.claude/hooks/hub-stop.js b/.claude/hooks/hub-stop.js index 1433754..0e3feb0 100644 --- a/.claude/hooks/hub-stop.js +++ b/.claude/hooks/hub-stop.js @@ -1,9 +1,9 @@ #!/usr/bin/env node /** - * hub-stop.js — Stop hook for Work-Down managed Claude sessions. + * hub-stop.js — Stop hook for dashboard-managed Claude sessions. * - * Only signals Hub when Work-Down specific env vars are present. - * Outside Work-Down dispatched sessions, this hook is a no-op. + * Signals the Dispatch dashboard when DISPATCH_* (or legacy HUB_*) env vars are present. + * Outside dispatched sessions, this hook is a no-op. */ const fs = require("fs"); @@ -50,9 +50,11 @@ function postJson(urlString, payload, callback) { } function main() { - const apiBase = process.env.HUB_API_BASE || ""; - const sessionId = process.env.HUB_SESSION_ID || ""; - const jobId = process.env.HUB_JOB_ID || ""; + const apiBase = + process.env.DISPATCH_API_BASE || process.env.HUB_API_BASE || ""; + const sessionId = + process.env.DISPATCH_SESSION_ID || process.env.HUB_SESSION_ID || ""; + const jobId = process.env.DISPATCH_JOB_ID || process.env.HUB_JOB_ID || ""; if (!apiBase || !sessionId || !jobId) { process.exit(0); diff --git a/.claude/skills/add-repo/SKILL.md b/.claude/skills/add-repo/SKILL.md index 32b4688..2aac9a9 100644 --- a/.claude/skills/add-repo/SKILL.md +++ b/.claude/skills/add-repo/SKILL.md @@ -5,7 +5,7 @@ argument-hint: [repo-name or path] allowed-tools: Read, Write, Edit, Bash(ls *), Bash(test *), Bash(mkdir *), Bash(cp *), Bash(node *) --- -# /add-repo — Add a Repository to the Hub +# /add-repo — Add a Repository to Dispatch Add a new repo to `config.json`, scaffold its tracking files, and install the hub completion hook so the dashboard can track dispatched jobs. @@ -19,7 +19,7 @@ Add a new repo to `config.json`, scaffold its tracking files, and install the hu node cli.js config ``` -Note the existing repos (for duplicate detection) and the hub's resolved path (you'll need it to locate the hook file to copy). +Note the existing repos (for duplicate detection) and the dispatch root’s resolved path (you'll need it to locate the hook file to copy). --- @@ -30,7 +30,7 @@ If `$ARGUMENTS` was provided, infer the repo name or path from it. Otherwise ask > **To add a repo I need a few details:** > > 1. **Repo name** — short identifier for the dashboard (e.g. `backend`, `mobile`, `docs`) -> 2. **Path** — relative path from the hub root (e.g. `../my-backend`) +> 2. **Path** — relative path from the dispatch root (e.g. `../my-backend`) > 3. **Start script** — command to run the dev server (e.g. `npm run dev`), or skip > 4. **Test script** — command to run tests, or skip > 5. **Cleanup script** — command to remove build artifacts, or skip diff --git a/.claude/skills/add-repo/references/formats.md b/.claude/skills/add-repo/references/formats.md index c8b0e0d..8203240 100644 --- a/.claude/skills/add-repo/references/formats.md +++ b/.claude/skills/add-repo/references/formats.md @@ -19,7 +19,7 @@ All fields except `name` and `path` are optional (use `null` if unknown). **Field notes:** - `name` — short identifier used in CLI output and dashboard labels. Lowercase, no spaces. -- `path` — relative path from the hub root. Always `../repo-name` for sibling repos, `.` for the hub itself. +- `path` — relative path from the dispatch root. Always `../repo-name` for sibling repos, `.` for the dispatch repo itself. - `taskFile` — almost always `todo.md`. Only change if the repo uses a different name. - `bugsFile` — optional. Set to `null` if the repo doesn't track bugs separately. - `activityFile` — almost always `activity-log.md`. @@ -27,7 +27,7 @@ All fields except `name` and `path` are optional (use `null` if unknown). - `testScript` — command to run tests. `null` if not applicable. - `cleanupScript` — command to clean build artifacts. `null` if not applicable. -The `hub` entry (path `.`) must always remain in the list and should not be modified. +The `dispatch` entry (path `.`) for this coordination repo must always remain in the list and should not be removed. --- diff --git a/AGENTS.md b/AGENTS.md index 1cc6dd0..dad5027 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,5 @@ Dispatch loads configuration from: In this checkout, **`config.local.json` is the effective source of truth** for repo paths and file locations. Do not assume `config.json` reflects the active local repo set. + +The dashboard server resolves the **dispatch root** with `DISPATCH_ROOT` (or legacy `HUB_DIR`), defaulting to the parent of `dashboard/`. Config may include **`dispatchRoot`** (camelCase JSON); legacy **`hubRoot`** is normalized to `dispatchRoot` on load. diff --git a/CLAUDE.md b/CLAUDE.md index 60bd137..f65e1fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,14 @@ Defined in `config.local.json` when present, otherwise `config.json`. In this ch | Field | Description | |-------|-------------| | `name` | Short name used in CLI output and dashboard | -| `path` | Relative path from hub root to the repo | +| `path` | Relative path from dispatch root to the repo | | `taskFile` | Markdown file for tasks (default: `todo.md`) | | `bugsFile` | Markdown file for bugs (default: `bugs.md`) | | `activityFile` | Markdown file for activity (default: `activity-log.md`) | | `startScript` | Command to start the repo's dev server | | `testScript` | Command to run tests | | `cleanupScript` | Command to clean build artifacts | +| `color` | Optional hex string (e.g. `#8bab8f`) for dashboard repo accents in `/api/overview`, activity feed, and filters | Every repo tracks tasks in `todo.md` and activity in `activity-log.md`. @@ -42,12 +43,12 @@ config.json ─── loadConfig() ───┐ ## Key Files - **config.local.json** -- Effective local repo definitions for this checkout. Dispatch loads this first when present. -- **config.json** -- Fallback/shared repo definitions when `config.local.json` is absent. `hubRoot` (display path) and `monthlyBudget` (optional) are user-specific; set `hubRoot` to your local hub path (e.g. `.` for current dir). Server falls back to `HUB_DIR` env var when `hubRoot` is unset. +- **config.json** -- Fallback/shared repo definitions when `config.local.json` is absent. `dispatchRoot` (display path) and `monthlyBudget` (optional) are user-specific; set `dispatchRoot` to your local dispatch root (e.g. `.` for current dir). Dashboard server falls back to `DISPATCH_ROOT` env var when unset (legacy `HUB_DIR` is still read for compatibility). Legacy `hubRoot` in JSON is normalized to `dispatchRoot` on load. - **parsers.js** -- CommonJS module. Primary job APIs: `parseJobFile`, `parseJobDir`, `writeJobValidation`, `writeJobKill`, `writeJobStatus`. Also owns task/activity parsing, task writes, and checkpoint helpers. Zero external dependencies. - **cli.js** -- Agent-friendly CLI. All output is JSON to stdout, errors as JSON to stderr. Commands: `status`, `tasks [--repo=name]`, `swarm [id]`, `repos`, `activity [--limit=N]`, `config`. - **terminal.js** -- Human-friendly ANSI terminal dashboard. Read-only display, no interactivity. Uses box-drawing characters. -- **todo.md** -- Hub's own task tracker (markdown checkboxes). -- **activity-log.md** -- Hub's own activity log. Contains `**Current stage:**` metadata. +- **todo.md** -- Dispatch root's own task tracker (markdown checkboxes). +- **activity-log.md** -- Dispatch root's own activity log. Contains `**Current stage:**` metadata. - **notes/jobs/** -- Job progress files (gitignored — runtime artifacts). Named `YYYY-MM-DD-slug.md`. - **.dispatch/runtime/** -- Server runtime state (gitignored): `.dispatch/runtime/job-runs.json` for job run state, `.dispatch/runtime/prompts/` for staged Claude prompts, `.dispatch/runtime/events/` for terminal event snapshots/NDJSON. @@ -110,7 +111,7 @@ yarn start # Serve built SPA + API from port 3747 ## Rules -1. **`config.local.json` is the source of truth when present; otherwise use `config.json`** for repo paths and file locations. In this repo, prefer `config.local.json`. Do not hardcode repo paths elsewhere. +1. **`config.local.json` is the source of truth when present; otherwise use `config.json`** for repo paths and file locations. In this repo, prefer `config.local.json`. Do not hardcode repo paths elsewhere. Use **`DISPATCH_ROOT`** (absolute path to the dispatch root) when overriding the server’s working directory; **`HUB_DIR`** is deprecated but still honored by the dashboard server. 2. **`parsers.js` is shared infrastructure.** Test changes against all three consumers (cli.js, terminal.js, dashboard/server.js) before committing. 3. **Job progress files** go in `notes/jobs/YYYY-MM-DD-slug.md` with the standard format (see Conventions above). 4. **All repos use the same task/activity pattern**: `todo.md` for tasks, `activity-log.md` for activity. diff --git a/cli.integration.test.js b/cli.integration.test.js index 58e9e57..954eedc 100644 --- a/cli.integration.test.js +++ b/cli.integration.test.js @@ -20,7 +20,7 @@ let cliPath; function setup() { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-int-')); - // Copy cli.js and parsers.js to tmp dir so HUB_DIR resolves to tmp + // Copy cli.js and parsers.js to tmp dir so DISPATCH_ROOT resolves to tmp cliPath = path.join(tmpDir, 'cli.js'); fs.copyFileSync(srcCliPath, cliPath); fs.copyFileSync(srcParsersPath, path.join(tmpDir, 'parsers.js')); @@ -81,7 +81,7 @@ function createTestHub(opts = {}) { activityFile: 'activity-log.md', }, ], - hubRoot: '.', + dispatchRoot: '.', }; if (opts.extraRepos) config.repos.push(...opts.extraRepos); fs.writeFileSync(path.join(hubDir, 'config.json'), JSON.stringify(config, null, 2)); diff --git a/cli.js b/cli.js index e3aeccc..fce625a 100755 --- a/cli.js +++ b/cli.js @@ -36,7 +36,7 @@ const { createCheckpoint, revertCheckpoint, dismissCheckpoint, listCheckpoints, } = require('./parsers'); -const HUB_DIR = path.dirname(__filename); +const DISPATCH_ROOT = path.dirname(__filename); function fail(msg) { process.stderr.write(JSON.stringify({ error: msg }) + '\n'); @@ -65,7 +65,7 @@ flags._positional = positionals[0] || null; // Load config let config; try { - config = loadConfig(HUB_DIR); + config = loadConfig(DISPATCH_ROOT); } catch { fail('config.local.json or config.json not found or invalid'); } diff --git a/config.json b/config.json index 59c2a93..4751e58 100644 --- a/config.json +++ b/config.json @@ -3,6 +3,7 @@ { "name": "example-app", "path": "../example-app", + "color": "#6366f1", "taskFile": "todo.md", "bugsFile": "bugs.md", "activityFile": "activity-log.md", @@ -13,6 +14,7 @@ { "name": "dispatch", "path": ".", + "color": "#8bab8f", "taskFile": "todo.md", "bugsFile": "bugs.md", "activityFile": "activity-log.md", @@ -21,5 +23,5 @@ "cleanupScript": "cd dashboard && rm -rf dist node_modules/.vite" } ], - "hubRoot": "." + "dispatchRoot": "." } diff --git a/dashboard/server.integration.test.mjs b/dashboard/server.integration.test.mjs index 3751b62..09c1bbe 100644 --- a/dashboard/server.integration.test.mjs +++ b/dashboard/server.integration.test.mjs @@ -19,7 +19,7 @@ let server; let baseUrl; /** - * Create a test hub filesystem structure and set HUB_DIR before importing server. + * Create a test dispatch root filesystem structure and set DISPATCH_ROOT before importing server. */ function createTestHub() { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dash-int-')); @@ -40,7 +40,7 @@ function createTestHub() { activityFile: 'activity-log.md', }, ], - hubRoot: '.', + dispatchRoot: '.', }, null, 2)); // todo.md @@ -109,7 +109,7 @@ before(async () => { const { repoDir } = createTestHub(); // Set env vars before importing server - process.env.HUB_DIR = tmpDir; + process.env.DISPATCH_ROOT = tmpDir; process.env.TESTING = '1'; const mod = await import('./server.js'); @@ -133,7 +133,7 @@ after(async () => { if (tmpDir) { fs.rmSync(tmpDir, { recursive: true, force: true }); } - delete process.env.HUB_DIR; + delete process.env.DISPATCH_ROOT; delete process.env.TESTING; }); diff --git a/dashboard/server.js b/dashboard/server.js index 06038dd..76d4bb7 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -55,13 +55,15 @@ const { writePlanStatus, } = require('../parsers') -const HUB_DIR = process.env.HUB_DIR ? path.resolve(process.env.HUB_DIR) : path.resolve(__dirname, '..') +const DISPATCH_ROOT = (process.env.DISPATCH_ROOT || process.env.HUB_DIR) + ? path.resolve(process.env.DISPATCH_ROOT || process.env.HUB_DIR) + : path.resolve(__dirname, '..') const PORT = process.env.PORT || 3747 /** When set, all `/api/*` requests must send this key (Bearer or X-API-Key). */ const DISPATCH_API_KEY = (process.env.DISPATCH_API_KEY || '').trim() /** Listen address (default 127.0.0.1). Use e.g. 0.0.0.0 for LAN access behind a firewall + API key. */ const DISPATCH_BIND = (process.env.DISPATCH_BIND || '127.0.0.1').trim() -const DISPATCH_DIR = path.join(HUB_DIR, '.dispatch') +const DISPATCH_DIR = path.join(DISPATCH_ROOT, '.dispatch') const RUNTIME_DIR = path.join(DISPATCH_DIR, 'runtime') const JOB_RUNS_FILE = path.join(RUNTIME_DIR, 'job-runs.json') const PROMPTS_DIR = path.join(RUNTIME_DIR, 'prompts') @@ -369,11 +371,11 @@ function cleanupPromptFile(filePath) { function buildClaudeEnvPrefix({ sessionId = null, jobId = null, repoName = null } = {}) { if (!sessionId || !jobId) return '' const envVars = { - HUB_API_BASE: `http://127.0.0.1:${PORT}`, - HUB_SESSION_ID: sessionId, - HUB_JOB_ID: jobId, + DISPATCH_API_BASE: `http://127.0.0.1:${PORT}`, + DISPATCH_SESSION_ID: sessionId, + DISPATCH_JOB_ID: jobId, } - if (repoName) envVars.HUB_REPO = repoName + if (repoName) envVars.DISPATCH_REPO = repoName return `${Object.entries(envVars).map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ')} ` } @@ -424,10 +426,10 @@ function buildTrackedClaudeCommand({ const claudeCommand = resumeId ? `${envPrefix}${buildResumeClaudeCommand(resumeId, flags)}` : `${envPrefix}claude${flags} "$(cat ${shellQuote(promptFilePath)})"` - const withExit = `${claudeCommand}; __hub_code=$?; echo "__HUB_CLAUDE_EXIT_CODE:\${__hub_code}__"; exit $__hub_code` + const withExit = `${claudeCommand}; __dispatch_code=$?; echo "__DISPATCH_CLAUDE_EXIT_CODE:\${__dispatch_code}__"; exit $__dispatch_code` // In plain output mode, wrap with sentinels so the server can capture Claude's // text response and write it to the job file's ## Results section. - return plainOutput ? `echo '__HUB_OUTPUT_START__'; ${withExit}` : withExit + return plainOutput ? `echo '__DISPATCH_OUTPUT_START__'; ${withExit}` : withExit } function buildTrackedCodexCommand({ @@ -448,7 +450,7 @@ function buildTrackedCodexCommand({ if (sanitizedCodex) flags += ` ${sanitizedCodex}` const envPrefix = buildClaudeEnvPrefix({ sessionId, jobId, repoName }) const cmd = `cat ${shellQuote(promptFilePath)} | ${envPrefix}codex exec ${flags} -` - return `${cmd}; __hub_code=$?; echo "__HUB_CLAUDE_EXIT_CODE:\${__hub_code}__"; exit $__hub_code` + return `${cmd}; __dispatch_code=$?; echo "__DISPATCH_CLAUDE_EXIT_CODE:\${__dispatch_code}__"; exit $__dispatch_code` } function buildTrackedCursorCommand({ @@ -476,8 +478,8 @@ function buildTrackedCursorCommand({ : cursorBinary === 'agent' ? `${envPrefix}agent -p "$(cat ${shellQuote(promptFilePath)})"${flags}` : `${envPrefix}cursor-agent${flags} "$(cat ${shellQuote(promptFilePath)})"` - const withExit = `${cursorCommand}; __hub_code=$?; echo "__HUB_CLAUDE_EXIT_CODE:\${__hub_code}__"; exit $__hub_code` - return plainOutput ? `echo '__HUB_OUTPUT_START__'; ${withExit}` : withExit + const withExit = `${cursorCommand}; __dispatch_code=$?; echo "__DISPATCH_CLAUDE_EXIT_CODE:\${__dispatch_code}__"; exit $__dispatch_code` + return plainOutput ? `echo '__DISPATCH_OUTPUT_START__'; ${withExit}` : withExit } function buildTrackedPiCommand({ @@ -499,8 +501,8 @@ function buildTrackedPiCommand({ if (sanitized) flags += ` ${sanitized}` const envPrefix = buildClaudeEnvPrefix({ sessionId, jobId, repoName }) const cmd = `${envPrefix}pi${flags} "$(cat ${shellQuote(promptFilePath)})"` - const withExit = `${cmd}; __hub_code=$?; echo "__HUB_CLAUDE_EXIT_CODE:\${__hub_code}__"; exit $__hub_code` - return plainOutput ? `echo '__HUB_OUTPUT_START__'; ${withExit}` : withExit + const withExit = `${cmd}; __dispatch_code=$?; echo "__DISPATCH_CLAUDE_EXIT_CODE:\${__dispatch_code}__"; exit $__dispatch_code` + return plainOutput ? `echo '__DISPATCH_OUTPUT_START__'; ${withExit}` : withExit } // Strip ANSI escape sequences for clean log output @@ -570,7 +572,7 @@ function removeJobWorktree(repoPath, jobId, { deleteBranch = false, repoName = ' } try { - loadConfig(HUB_DIR) + loadConfig(DISPATCH_ROOT) } catch (err) { console.error('Failed to load config.local.json or config.json:', err.message) process.exit(1) @@ -581,7 +583,7 @@ const gitInfoCache = new Map() // repoPath -> { value, expiresAt } const planLookupCache = { signature: null, lookup: null } function getConfig() { - return loadConfig(HUB_DIR) + return loadConfig(DISPATCH_ROOT) } function getCachedGitInfo(repoPath) { @@ -699,7 +701,7 @@ function emitJobsChanged({ repo = null, id = null, reason = null } = {}) { } function collectAvailableSkills() { - const localSkills = parseSkillsDir(HUB_DIR, { + const localSkills = parseSkillsDir(DISPATCH_ROOT, { source: 'local', includeSource: true, idPrefix: 'local:', @@ -782,6 +784,7 @@ app.get('/api/overview', (req, res) => { return { name: repo.name, + ...(repo.color ? { color: repo.color } : {}), git, tasks: { openCount: tasks.openCount, doneCount: tasks.doneCount, sections: tasks.sections, allTasks: tasks.allTasks }, bugs: { openCount: bugs.openCount, doneCount: bugs.doneCount, sections: bugs.sections, allTasks: bugs.allTasks }, @@ -795,7 +798,7 @@ app.get('/api/overview', (req, res) => { const totalOpen = repos.reduce((s, r) => s + r.tasks.openCount, 0) const totalDone = repos.reduce((s, r) => s + r.tasks.doneCount, 0) - res.json({ hubRoot: config.hubRoot || HUB_DIR, stage, repos, totals: { openTasks: totalOpen, doneTasks: totalDone }, monthlyBudget: config.monthlyBudget || null }) + res.json({ dispatchRoot: config.dispatchRoot || DISPATCH_ROOT, stage, repos, totals: { openTasks: totalOpen, doneTasks: totalDone }, monthlyBudget: config.monthlyBudget || null }) }) function collectJobAgents(config = getConfig()) { @@ -1056,7 +1059,7 @@ app.post('/api/loops/init', (req, res) => { // Build shell command — no job file, loop.log is the source of truth const scriptPath = `loops/${loopType}.sh` - let shellCommand = `cd "${HUB_DIR}" && bash "${scriptPath}" --repo "${repoConfig.resolvedPath}" --session "${sessionId}"` + let shellCommand = `cd "${DISPATCH_ROOT}" && bash "${scriptPath}" --repo "${repoConfig.resolvedPath}" --session "${sessionId}"` if (loopType === 'parallel-review') { const agents = Array.isArray(reviewerAgents) && reviewerAgents.length > 0 ? reviewerAgents : ['claude'] for (const a of agents) shellCommand += ` --agent "${String(a).replace(/"/g, '')}"` @@ -1328,7 +1331,12 @@ app.get('/api/activity', (req, res) => { const activity = parseActivityLog(path.join(repo.resolvedPath, repo.activityFile)) for (const entry of activity.entries) { if (entry.bullet) { - allEntries.push({ date: entry.date, bullet: entry.bullet, repo: repo.name }) + allEntries.push({ + date: entry.date, + bullet: entry.bullet, + repo: repo.name, + ...(repo.color ? { color: repo.color } : {}), + }) } } } @@ -1562,6 +1570,7 @@ app.get('/api/catalog', async (req, res) => { taskFile: r.taskFile, activityFile: r.activityFile, ...(r.bugsFile ? { bugsFile: r.bugsFile } : {}), + ...(r.color ? { color: r.color } : {}), })) const models = {} const modelSources = {} @@ -1573,7 +1582,7 @@ app.get('/api/catalog', async (req, res) => { modelSources[agent] = result.source } res.json({ - hubRoot: config.hubRoot || HUB_DIR, + dispatchRoot: config.dispatchRoot || DISPATCH_ROOT, repos, agents: DISPATCH_AGENT_KINDS.map(id => ({ id, label: DISPATCH_AGENT_LABELS[id] || id })), models, @@ -2134,7 +2143,7 @@ app.post(['/api/jobs/:id/resume', '/api/swarm/:id/resume'], (req, res) => { const cwd = detail.worktreePath && detail.worktreePath !== '(merged)' ? detail.worktreePath - : found.repo?.resolvedPath || HUB_DIR + : found.repo?.resolvedPath || DISPATCH_ROOT createPtySession( sessionId, cwd, @@ -2574,7 +2583,7 @@ app.delete('/api/repos/:name/checkpoint/:id', (req, res) => { // ── Schedules CRUD ────────────────────────────────────── -const SCHEDULES_FILE = path.join(HUB_DIR, 'schedules.json') +const SCHEDULES_FILE = path.join(DISPATCH_ROOT, 'schedules.json') function loadSchedules() { try { @@ -2909,7 +2918,7 @@ function createPtySession(sessionId, cwd, repoName, jobFilePath, initialScrollba eventStore: createSessionEventStore({ sessionId, repo: repoName || null, - baseDir: HUB_DIR, + baseDir: DISPATCH_ROOT, }), } @@ -3029,18 +3038,21 @@ function createPtySession(sessionId, cwd, repoName, jobFilePath, initialScrollba shell.onData((data) => { let chunk = String(data || '') - const exitMatch = chunk.match(/__HUB_CLAUDE_EXIT_CODE:(\d+)__/) + const exitMatch = chunk.match(/__DISPATCH_CLAUDE_EXIT_CODE:(\d+)__/) + || chunk.match(/__HUB_CLAUDE_EXIT_CODE:(\d+)__/) if (exitMatch) { session.commandExitCode = parseInt(exitMatch[1], 10) + chunk = chunk.replace(/__DISPATCH_CLAUDE_EXIT_CODE:\d+__/g, '') chunk = chunk.replace(/__HUB_CLAUDE_EXIT_CODE:\d+__/g, '') } // Plain output mode: capture Claude's text response between sentinels // and write to the job file's ## Results section when done. if (session.plainOutput) { - if (chunk.includes('__HUB_OUTPUT_START__')) { + if (chunk.includes('__DISPATCH_OUTPUT_START__') || chunk.includes('__HUB_OUTPUT_START__')) { session.capturingPlainOutput = true session.plainOutputBuffer = '' + chunk = chunk.replace(/[^\n]*__DISPATCH_OUTPUT_START__[^\n]*\r?\n?/g, '') chunk = chunk.replace(/[^\n]*__HUB_OUTPUT_START__[^\n]*\r?\n?/g, '') } if (session.capturingPlainOutput) { @@ -3329,7 +3341,7 @@ wss.on('connection', (ws, req) => { } } else { // New session — resolve cwd and spawn PTY - let cwd = HUB_DIR + let cwd = DISPATCH_ROOT if (repoName) { const config = getConfig() const repoConfig = config.repos.find(r => r.name === repoName) diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index cb6c68c..f46b63d 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -195,6 +195,7 @@ export default function App() { {items.map((item, ii) => { - const dotColor = repoIdentityColors[item.repo] || 'var(--muted-foreground)' + const dotColor = item.color || DEFAULT_REPO_MUTED_COLOR return (
diff --git a/dashboard/src/components/ActivityTimeline.jsx b/dashboard/src/components/ActivityTimeline.jsx index 63de62a..6fc4d11 100644 --- a/dashboard/src/components/ActivityTimeline.jsx +++ b/dashboard/src/components/ActivityTimeline.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Clock } from 'lucide-react' import { cn } from '../lib/utils' -import { repoIdentityColors } from '../lib/constants' +import { DEFAULT_REPO_MUTED_COLOR } from '../lib/constants' function formatRelativeDate(dateStr) { if (!dateStr) return '' @@ -127,7 +127,7 @@ export default function ActivityTimeline() { {/* Entries under this date */}
{group.items.map((item, ii) => { - const dotColor = repoIdentityColors[item.repo] || 'var(--muted-foreground)' + const dotColor = item.color || DEFAULT_REPO_MUTED_COLOR return (
setRepoDropdownOpen(p => !p)} className="flex items-center gap-1.5 px-3 py-2.5 text-[12px] font-medium capitalize" - style={newTaskRepo && repoIdentityColors[newTaskRepo] - ? { color: repoIdentityColors[newTaskRepo] } + style={newTaskRepo + ? { color: getRepoColor(overview, newTaskRepo, 'var(--muted-foreground)') } : { color: 'var(--muted-foreground)' }} > {newTaskRepo || 'Repo'} @@ -300,7 +300,7 @@ export default function AllTasksView({ {repoDropdownOpen && (
{repoNames.map(name => { - const color = repoIdentityColors[name] + const color = getRepoColor(overview, name) return (
@@ -429,7 +429,7 @@ export default function AllTasksView({ const doneTasks = filteredTasks.filter(t => t.status === 'done') const renderTask = (task, i) => { - const repoColor = repoIdentityColors[task.repoName] || 'var(--primary)' + const repoColor = getRepoColor(overview, task.repoName) const isClickable = (task.status === 'in_progress' || task.status === 'review') && task.jobId const taskKey = `${task.repoName}-${task.section}-${task.status}-${i}` const isEditing = editingTaskKey === taskKey diff --git a/dashboard/src/components/DispatchView.jsx b/dashboard/src/components/DispatchView.jsx index 8a823d8..e2e7aff 100644 --- a/dashboard/src/components/DispatchView.jsx +++ b/dashboard/src/components/DispatchView.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { Send, FileText, X, RefreshCcw } from 'lucide-react' import { cn } from '../lib/utils' -import { repoIdentityColors } from '../lib/constants' +import { DEFAULT_REPO_COLOR } from '../lib/constants' import { useAgentModels } from '../lib/useAgentModels' import { useSkills } from '../lib/useSkills' import DispatchSettingsRow from './DispatchSettingsRow' @@ -225,7 +225,7 @@ export default function DispatchView({ overview, onDispatch, onLoopDispatch, ini
{repos.map(r => { - const color = repoIdentityColors[r.name] || 'var(--primary)' + const color = r.color || DEFAULT_REPO_COLOR const isSelected = repo === r.name return (
diff --git a/dashboard/src/components/JobsView.jsx b/dashboard/src/components/JobsView.jsx index 26d52c8..a177eb2 100644 --- a/dashboard/src/components/JobsView.jsx +++ b/dashboard/src/components/JobsView.jsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react' import { Bot, Sparkles, AlertCircle, CheckCircle2, XCircle, Clock, GitBranch, AlertTriangle, Square, Loader, Eye, EyeOff } from 'lucide-react' import { cn, timeAgo, truncateWithEllipsis, buildPlanPath } from '../lib/utils' -import { repoIdentityColors, normalizeAgentId, getAgentBrandColor } from '../lib/constants' +import { getRepoColor, normalizeAgentId, getAgentBrandColor } from '../lib/constants' import { buildWorkerNavItems } from '../lib/workerUtils' import { FilterChip, toggleFilter, BUG_COLOR, loadFilters, saveFilters } from '../lib/filterUtils.jsx' import AgentIcon, { getAgentLabel } from './AgentIcon' @@ -291,7 +291,7 @@ export default function JobsView({ label={name} active={selectedRepos.has(name)} onClick={() => toggleFilter(selectedRepos, setSelectedRepos, name)} - color={repoIdentityColors[name]} + color={getRepoColor(overview, name)} /> ))}
@@ -337,7 +337,7 @@ export default function JobsView({
)} {group.items.map(worker => { - const repoColor = repoIdentityColors[worker.repo] || 'var(--primary)' + const repoColor = getRepoColor(overview, worker.repo) const normalizedAgent = normalizeAgentId(worker.agent) const agentLabel = getAgentLabel(normalizedAgent) const agentColor = getAgentBrandColor(normalizedAgent) diff --git a/dashboard/src/components/LoopDetailView.jsx b/dashboard/src/components/LoopDetailView.jsx index 4103a2e..8c45d90 100644 --- a/dashboard/src/components/LoopDetailView.jsx +++ b/dashboard/src/components/LoopDetailView.jsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect, useRef } from 'react' import { ArrowLeft, TerminalSquare, ClipboardCheck, Clock, RefreshCcw } from 'lucide-react' import { cn, timeAgo } from '../lib/utils' -import { repoIdentityColors, normalizeAgentId, getAgentBrandColor } from '../lib/constants' +import { getRepoColor, normalizeAgentId, getAgentBrandColor } from '../lib/constants' import { LOOP_TYPE_META } from '../lib/loopConstants' import AgentIcon, { getAgentLabel } from './AgentIcon' import TerminalPanel from './TerminalPanel' @@ -17,6 +17,7 @@ const STATUS_COLORS = { export default function LoopDetailView({ loopId, loops, + overview, onBack, agentTerminals, onKillSession, @@ -101,7 +102,7 @@ export default function LoopDetailView({ const meta = LOOP_TYPE_META[loop.loopType] || { label: loop.loopType, icon: RefreshCcw } const TypeIcon = meta.icon - const repoColor = repoIdentityColors[loop.repo] || 'var(--primary)' + const repoColor = getRepoColor(overview, loop.repo) const statusColor = STATUS_COLORS[loop.status] || STATUS_COLORS.unknown const agentId = normalizeAgentId((loop.agent || 'claude').split(':')[0]) const agentLabel = getAgentLabel(agentId) @@ -198,7 +199,7 @@ export default function LoopDetailView({
- +
diff --git a/dashboard/src/components/LoopReviewPanel.jsx b/dashboard/src/components/LoopReviewPanel.jsx index 2f50d7f..e19ee47 100644 --- a/dashboard/src/components/LoopReviewPanel.jsx +++ b/dashboard/src/components/LoopReviewPanel.jsx @@ -3,7 +3,7 @@ import { Clock, RefreshCcw, CheckCircle2, XCircle, Loader2, ChevronRight } from import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { cn, timeAgo } from '../lib/utils' -import { repoIdentityColors, normalizeAgentId, getAgentBrandColor } from '../lib/constants' +import { getRepoColor, normalizeAgentId, getAgentBrandColor } from '../lib/constants' import { LOOP_TYPE_META } from '../lib/loopConstants' import { usePolling } from '../lib/usePolling' import { mdComponents } from './mdComponents' @@ -137,10 +137,10 @@ function summarizeArtifact(text, type) { return { status: verdict || 'Open artifact', details: [] } } -export default function LoopReviewPanel({ loop }) { +export default function LoopReviewPanel({ loop, overview }) { const meta = LOOP_TYPE_META[loop?.loopType] || { label: loop?.loopType || 'Unknown', icon: RefreshCcw } const TypeIcon = meta.icon - const repoColor = repoIdentityColors[loop?.repo] || 'var(--primary)' + const repoColor = getRepoColor(overview, loop?.repo) const agentId = normalizeAgentId((loop?.agent || 'claude').split(':')[0]) const agentLabel = getAgentLabel(agentId) const agentColor = getAgentBrandColor(agentId) diff --git a/dashboard/src/components/LoopsView.jsx b/dashboard/src/components/LoopsView.jsx index b936844..9f73826 100644 --- a/dashboard/src/components/LoopsView.jsx +++ b/dashboard/src/components/LoopsView.jsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect } from 'react' import { RefreshCcw, Clock, Code2, ScanSearch, GitFork, Sparkles, CheckCircle2, XCircle } from 'lucide-react' import { cn, timeAgo } from '../lib/utils' -import { repoIdentityColors } from '../lib/constants' +import { getRepoColor } from '../lib/constants' import { FilterChip, toggleFilter, loadFilters, saveFilters } from '../lib/filterUtils.jsx' import { LOOP_TYPES } from './AgentModelPicker' @@ -124,7 +124,7 @@ export default function LoopsView({ loops, overview, onSelectLoop }) { label={name} active={selectedRepos.has(name)} onClick={() => toggleFilter(selectedRepos, setSelectedRepos, name)} - color={repoIdentityColors[name]} + color={getRepoColor(overview, name)} /> ))} @@ -158,7 +158,7 @@ export default function LoopsView({ loops, overview, onSelectLoop }) { )} {group.items.map(job => { - const repoColor = repoIdentityColors[job.repo] || 'var(--primary)' + const repoColor = getRepoColor(overview, job.repo) const statusColor = STATUS_COLORS[job.filterStatus] || STATUS_COLORS.active const duration = job.durationMinutes != null ? timeAgo(null, job.durationMinutes) : null const TypeIcon = getLoopTypeIcon(job.loopType) diff --git a/dashboard/src/components/PlansView.jsx b/dashboard/src/components/PlansView.jsx index 1ec5d20..d37eec7 100644 --- a/dashboard/src/components/PlansView.jsx +++ b/dashboard/src/components/PlansView.jsx @@ -8,7 +8,7 @@ import SkillsSelector from './SkillsSelector' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { buildPlanPath, cn, truncateWithEllipsis } from '../lib/utils' -import { repoIdentityColors } from '../lib/constants' +import { DEFAULT_REPO_COLOR, getRepoColor } from '../lib/constants' import { getFilterChipClassName, getFilterChipStyle } from '../lib/filterUtils.jsx' import { mdComponents } from './mdComponents' @@ -101,8 +101,8 @@ function CountBadge({ n }) { ) } -function PlanCard({ plan, onSelect }) { - const color = repoIdentityColors[plan.repo] || 'var(--primary)' +function PlanCard({ plan, onSelect, overview }) { + const color = getRepoColor(overview, plan.repo) return ( {repos.map(r => { - const color = repoIdentityColors[r.name] || 'var(--primary)' + const color = r.color || DEFAULT_REPO_COLOR const isActive = repoFilter === r.name return (