Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci-test-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: ci-test-build

on:
pull_request:

jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24

- name: Enable Corepack
run: corepack enable

- name: Setup Yarn
run: corepack prepare yarn@4.6.0 --activate

- name: Install dependencies
working-directory: dashboard
run: yarn install --immutable

- name: Run tests
working-directory: dashboard
run: yarn test

- name: Build dashboard
working-directory: dashboard
run: yarn build
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ dashboard/.yarn/install-state.gz
# Package lock (using Yarn, not npm)
dashboard/package-lock.json

# Runtime state (generated by the server)
# Runtime state (generated by the server; keep legacy path ignored during migration)
.dispatch/runtime/
.hub-runtime/

# Job progress files (runtime artifacts, project-specific)
Expand Down
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Dispatch Agent Notes

See `CLAUDE.md` for the full project guide.

## Important config rule

Dispatch loads configuration from:
1. `config.local.json` when present
2. otherwise `config.json`

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.
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A multi-repo coordination hub for Claude Code agent workflows. Aggregates tasks,

## Repos

Defined in `config.json` (source of truth for all repo paths). Edit this to point to your repos.
Defined in `config.local.json` when present, otherwise `config.json`. In this checkout, `config.local.json` is the effective source of truth for repo paths. Edit that first.

| Field | Description |
|-------|-------------|
Expand Down Expand Up @@ -41,20 +41,21 @@ config.json ─── loadConfig() ───┐

## Key Files

- **config.json** -- Repo definitions. `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.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.
- **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.
- **notes/jobs/** -- Job progress files (gitignored — runtime artifacts). Named `YYYY-MM-DD-slug.md`.
- **.hub-runtime/** -- Server runtime state (gitignored): `.hub-runtime/job-runs.json` for job run state, `.hub-runtime/prompts/` for staged Claude prompts, `.hub-runtime/events/` for terminal event snapshots/NDJSON.
- **.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.

### Dashboard (`dashboard/`)

Separate Node.js project with its own `package.json` (ESM, `"type": "module"`).

- **server.js** -- Express backend on port 3747. Imports `../parsers.js` via `createRequire`. REST API for overview, tasks, bugs, jobs (`/api/jobs`), sessions, schedules, checkpoints, and events. WebSocket terminal server (`/ws/terminal`) keeps PTY sessions alive across reconnects, stages server-managed Claude launches/resumes, persists run state in `.hub-runtime/job-runs.json`.
- **server.js** -- Express backend on port 3747. Imports `../parsers.js` via `createRequire`. REST API for overview, tasks, bugs, jobs (`/api/jobs`), sessions, schedules, checkpoints, and events. WebSocket terminal server (`/ws/terminal`) keeps PTY sessions alive across reconnects, stages server-managed Claude launches/resumes, persists run state in `.dispatch/runtime/job-runs.json`.
- **eventPipeline.js** -- Captures terminal output into structured NDJSON events. Line classification, agent detection, event search, session summaries.
- **src/** -- React SPA with Tailwind CSS v4. Navigation: `ActivityBar` (icon tabs) → views (`StatusView`, `JobsView`, `AllTasksView`, `DispatchView`, `SchedulesView`) with `JobDetailView` drill-down. Hooks: `usePolling`, `useSessionStore`, `useTerminal`, `useSearch`.
- **vite.config.js** -- Proxies `/api` and `/ws` to `localhost:3747` during dev.
Expand Down Expand Up @@ -109,7 +110,7 @@ yarn start # Serve built SPA + API from port 3747

## Rules

1. **`config.json` is the source of truth** for repo paths and file locations. 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.
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.
Expand Down
26 changes: 26 additions & 0 deletions activity-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

**Current stage:** Getting started

## 2026-04-07

- **Use the correct logo for Pi https://camo.githubusercontent.com/8b5a446dcbd5bea234898b8584e5484099dc0a939d8e59e542b7f5f23b259217/68747470733a2f2f736869747479636f64696e676167656e742e61692f6c6f676f2e737667**
- **- Reorder active jobs to the top of the jobs list - Add a new status boolean for "Read" jobs- those in review, that I've read but i want to hide for now. Marking a job as read is done on the jobs list row with a eye icon button. Then in the review group, add a toggle to show/hide the "read" jobs.**
- **Add a Stop job button for dangling / orphaned jobs**
- **Add forward-and-backward linking to job details for related jobs. Eg. dispatching a follow-up job will create a new job. and on that new job i want to be able to easily navigate to the previous job. Maybe at the top of the job page we have a simple navigation <-- [Previous_job_name] and if there is a chain of jobs then something like this: [<-- [revious_job_name]. [next_job_name -->]**
- **Please ensure this is persisted across page loads and navigation --- Previous job context: notes/jobs/2026-04-07-reorder-active-jobs-to-the-top-of-the-jobs-list-ad.md**
- **Use `gh pr view` to pull the latest claude code review. Then identify which issues remain and address each high and medium finding with a fix.**
- **I marked a plan as complete, but it still appears in the list as needing review --- Previous job context: notes/jobs/2026-04-07-add-the-ability-to-mark-a-plan-as-complete-without.md**

---

## 2026-04-05

- **/add-repo ../local-ai-hardware/darby and make the color deep blue**

---

## 2026-04-02

- **Please implement code diffs in the markdown rendering for loops and tasks**
- **Determine why dispatch is failing for cursor agents**
- **Is that the correct way to do this? I still see many listed as active on the loops page --- Previous job context: notes/jobs/2026-04-02-please-mark-all-existing-loops-for-prompt-guard-an.md**

---

## 2026-03-31

- **Full loop.log ownership** — eliminated job files for loops; scripts write structured headers (LOOP_SESSION, LOOP_TYPE, LOOP_AGENT, LOOP_STARTED) and LOOP_STATUS to loop.log; added parseLoopRun/parseAllLoopRuns to parsers.js; rewrote /api/loops to scan .dispatch/loops/ dirs; created dedicated LoopDetailView with own /loops/:type/:timestamp route and terminal panel. skills: /done
Expand Down
2 changes: 1 addition & 1 deletion cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function swarmSummary(agents) {
if (a.status === 'in_progress') active++;
else if (a.status === 'completed') completed++;
else if (a.status === 'failed') failed++;
if (a.validation === 'needs_validation') needsValidation++;
if (a.validation === 'needs_validation' || a.status === 'stopped') needsValidation++;
}
return { active, completed, failed, needsValidation };
}
Expand Down
36 changes: 0 additions & 36 deletions config.example.json

This file was deleted.

3 changes: 3 additions & 0 deletions dashboard/env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copy to .env.local for Vite dev (optional).
# DISPATCH_API_KEY= # must match hub server — proxy sends this to /api and /ws
# VITE_DISPATCH_API_KEY= # for production build when hub requires auth (same value)
14 changes: 12 additions & 2 deletions dashboard/eventPipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ const MAX_EVENT_RAW_CHARS = 1000

function stripAnsiForParse(str) {
return (str || '')
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\[[\x20-\x3f]*[\x40-\x7e]/g, '')
.replace(/\x1b\][^\x07]*\x07/g, '')
.replace(/\x1b\([A-Z]/g, '')
.replace(/\x1b[=>]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
.replace(/\[(?:\?|>)[0-9;]*[a-zA-Z]/g, '')
}

function detectAgentKind(state, text) {
Expand All @@ -29,6 +30,10 @@ function classifyLine(line) {

if (!t) return { kind: 'system', level: 'info', text: '' }

if (/usage.?limit|rate.?limit|too many requests|quota exceeded|credits?\s*(expired|ran\s*out|depleted)|hit your.{0,10}limit|try again at\b/i.test(lower)) {
return { kind: 'error', level: 'error', text: t, subKind: 'rate_limit' }
}

if (/\berror\b|\bfailed\b|exception|traceback|fatal/.test(lower)) {
return { kind: 'error', level: 'error', text: t }
}
Expand Down Expand Up @@ -68,6 +73,7 @@ function parseLines(lines, baseState, adapterKind) {
raw: clean,
meta: {
parser: adapterKind,
...(classified.subKind ? { subKind: classified.subKind } : {}),
},
})
}
Expand Down Expand Up @@ -159,7 +165,7 @@ export function parseChunkWithAdapters(chunk, state = {}) {

export function createSessionEventStore({ sessionId, repo, baseDir, ringSize = 1200 }) {
const created = Date.now()
const runtimeDir = path.join(baseDir, '.hub-runtime', 'events')
const runtimeDir = path.join(baseDir, '.dispatch', 'runtime', 'events')
fs.mkdirSync(runtimeDir, { recursive: true })
const snapshotPath = path.join(runtimeDir, `${sessionId}.ndjson`)

Expand All @@ -174,6 +180,8 @@ export function createSessionEventStore({ sessionId, repo, baseDir, ringSize = 1
summary: {
lastStep: null,
lastError: null,
lastErrorSubKind: null,
errorCount: 0,
filesTouched: [],
toolCalls: 0,
},
Expand Down Expand Up @@ -235,6 +243,8 @@ export function appendChunkToEventStore(store, chunk) {
}
if (full.level === 'error' || full.kind === 'error') {
store.summary.lastError = full.text
store.summary.lastErrorSubKind = full.meta?.subKind || null
store.summary.errorCount = (store.summary.errorCount || 0) + 1
}
if (full.kind === 'tool') {
store.summary.toolCalls += 1
Expand Down
76 changes: 72 additions & 4 deletions dashboard/server.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ function createTestHub() {
'- Set up test hub',
].join('\n'));

// .hub-runtime directory
fs.mkdirSync(path.join(tmpDir, '.hub-runtime'), { recursive: true });
// .dispatch/runtime directory
fs.mkdirSync(path.join(tmpDir, '.dispatch', 'runtime'), { recursive: true });

// Init git repo
execSync(`git init -b main "${repoDir}"`, { encoding: 'utf8' });
Expand Down Expand Up @@ -200,6 +200,19 @@ describe('Dashboard GET endpoints', () => {
assert.equal(json.repos[0].name, 'testrepo');
});

it('GET /api/catalog returns repos, agents, models, modelSources', async () => {
const { status, json } = await api('GET', '/api/catalog');
assert.equal(status, 200);
assert.ok(Array.isArray(json.repos));
assert.equal(json.repos[0].name, 'testrepo');
assert.ok(json.repos[0].taskFile);
assert.ok(Array.isArray(json.agents));
assert.ok(json.agents.some((a) => a.id === 'claude'));
assert.ok(json.models && typeof json.models === 'object');
assert.ok(json.models.claude && Array.isArray(json.models.claude));
assert.ok(json.modelSources && json.modelSources.claude);
});

it('GET /api/overview returns stage, repos, totals', async () => {
const { status, json } = await api('GET', '/api/overview');
assert.equal(status, 200);
Expand Down Expand Up @@ -285,6 +298,60 @@ describe('Dashboard POST write endpoints', () => {
assert.ok(json.success);
});

it('POST /api/jobs/init links follow-up jobs linearly', async () => {
const repoDir = path.join(tmpDir, 'testrepo');
createJobFile(repoDir, '2026-03-26-parent-job');

const { status, json } = await api('POST', '/api/jobs/init', {
repo: 'testrepo',
taskText: 'Follow up on parent job',
previousJobId: '2026-03-26-parent-job',
});
assert.equal(status, 200);
assert.ok(json.fileName);

const childId = json.fileName.replace(/\.md$/, '');
const childContent = fs.readFileSync(
path.join(repoDir, 'notes', 'jobs', `${childId}.md`),
'utf8'
);
const parentContent = fs.readFileSync(
path.join(repoDir, 'notes', 'jobs', '2026-03-26-parent-job.md'),
'utf8'
);

assert.ok(childContent.includes('PreviousJob: 2026-03-26-parent-job'));
assert.ok(parentContent.includes(`NextJob: ${childId}`));

await api('DELETE', `/api/sessions/${encodeURIComponent(json.sessionId)}`);
await api('DELETE', `/api/sessions/${encodeURIComponent(json.sessionId)}/purge`);
});

it('POST /api/jobs/init rejects branching from a job that already has a next link', async () => {
const repoDir = path.join(tmpDir, 'testrepo');
const filePath = path.join(repoDir, 'notes', 'jobs', '2026-03-26-linear-parent.md');
fs.writeFileSync(filePath, [
'# Job Task: Linear parent',
'Started: 2026-03-26 12:00:00',
'Status: Completed',
'Validation: Needs validation',
'Repo: testrepo',
'NextJob: 2026-03-26-existing-child',
'',
'## Progress',
'',
'## Results',
].join('\n'));

const { status, json } = await api('POST', '/api/jobs/init', {
repo: 'testrepo',
taskText: 'Attempt second follow up',
previousJobId: '2026-03-26-linear-parent',
});
assert.equal(status, 409);
assert.ok(json.error);
});

it('POST /api/jobs/:id/validate sets validation to Validated', async () => {
const repoDir = path.join(tmpDir, 'testrepo');
createJobFile(repoDir, '2026-03-26-validate-test');
Expand All @@ -300,7 +367,7 @@ describe('Dashboard POST write endpoints', () => {
assert.ok(content.includes('Validation: Validated'));
});

it('POST /api/jobs/:id/kill sets status to Killed', async () => {
it('POST /api/jobs/:id/kill sets status to Stopped and keeps it reviewable', async () => {
const repoDir = path.join(tmpDir, 'testrepo');
createJobFile(repoDir, '2026-03-26-kill-test', { status: 'In progress' });

Expand All @@ -310,7 +377,8 @@ describe('Dashboard POST write endpoints', () => {
const content = fs.readFileSync(
path.join(repoDir, 'notes', 'jobs', '2026-03-26-kill-test.md'), 'utf8'
);
assert.ok(content.includes('Status: Killed'));
assert.ok(content.includes('Status: Stopped'));
assert.ok(content.includes('Validation: Needs validation'));
});
});

Expand Down
Loading
Loading