harness-async 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/dist/dashboard/assets/index-TGNGdtwt.js +246 -0
  4. package/dist/dashboard/assets/index-f4TpA4iP.css +1 -0
  5. package/dist/dashboard/index.html +13 -0
  6. package/dist/src/adapters/claude-adapter.js +52 -0
  7. package/dist/src/adapters/codex-adapter.js +55 -0
  8. package/dist/src/adapters/index.js +14 -0
  9. package/dist/src/adapters/shared.js +74 -0
  10. package/dist/src/cli/commands/daemon.js +116 -0
  11. package/dist/src/cli/commands/doctor.js +50 -0
  12. package/dist/src/cli/commands/hook.js +188 -0
  13. package/dist/src/cli/commands/init.js +22 -0
  14. package/dist/src/cli/commands/run.js +129 -0
  15. package/dist/src/cli/commands/schedule.js +105 -0
  16. package/dist/src/cli/commands/task.js +188 -0
  17. package/dist/src/cli/index.js +23 -0
  18. package/dist/src/cli/utils/notify.js +32 -0
  19. package/dist/src/cli/utils/output.js +94 -0
  20. package/dist/src/core/daemon.js +375 -0
  21. package/dist/src/core/dag.js +80 -0
  22. package/dist/src/core/event-log.js +34 -0
  23. package/dist/src/core/lock.js +25 -0
  24. package/dist/src/core/run-manager.js +265 -0
  25. package/dist/src/core/run-orchestrator.js +193 -0
  26. package/dist/src/core/scheduler.js +106 -0
  27. package/dist/src/core/sessions.js +48 -0
  28. package/dist/src/core/store.js +225 -0
  29. package/dist/src/core/task-manager.js +375 -0
  30. package/dist/src/core/tmux.js +51 -0
  31. package/dist/src/daemon.js +35 -0
  32. package/dist/src/dashboard/routes.js +107 -0
  33. package/dist/src/dashboard/server.js +142 -0
  34. package/dist/src/dashboard/ws.js +75 -0
  35. package/dist/src/types/adapter.js +30 -0
  36. package/dist/src/types/index.js +87 -0
  37. package/package.json +65 -0
@@ -0,0 +1,107 @@
1
+ import { Hono } from 'hono';
2
+ import { collectRunOutput, getRunAttachCommand, syncRunState } from '../core/run-orchestrator.js';
3
+ import { listRuns } from '../core/run-manager.js';
4
+ import { listSchedules } from '../core/scheduler.js';
5
+ import { getTask, getTaskEvents, getTaskGraph, listTasks } from '../core/task-manager.js';
6
+ export function createDashboardRoutes(context) {
7
+ const app = new Hono();
8
+ app.get('/api/tasks', async (c) => {
9
+ const scope = parseScope(c.req.query('scope'));
10
+ const tasks = await listTasks({
11
+ cwd: context.cwd,
12
+ homeDir: context.homeDir,
13
+ scope,
14
+ });
15
+ return c.json(tasks);
16
+ });
17
+ app.get('/api/tasks/:id', async (c) => {
18
+ try {
19
+ const task = await getTask({
20
+ cwd: context.cwd,
21
+ homeDir: context.homeDir,
22
+ id: Number(c.req.param('id')),
23
+ scope: parseScope(c.req.query('scope')),
24
+ });
25
+ return c.json(task);
26
+ }
27
+ catch (error) {
28
+ return c.json({ message: error.message }, 404);
29
+ }
30
+ });
31
+ app.get('/api/tasks/:id/events', async (c) => {
32
+ try {
33
+ const events = await getTaskEvents({
34
+ cwd: context.cwd,
35
+ homeDir: context.homeDir,
36
+ id: Number(c.req.param('id')),
37
+ scope: parseScope(c.req.query('scope')),
38
+ });
39
+ return c.json(events);
40
+ }
41
+ catch (error) {
42
+ return c.json({ message: error.message }, 404);
43
+ }
44
+ });
45
+ app.get('/api/tasks/:id/runs', async (c) => {
46
+ try {
47
+ const scope = parseScope(c.req.query('scope'));
48
+ const runs = await listRuns({
49
+ cwd: context.cwd,
50
+ homeDir: context.homeDir,
51
+ scope,
52
+ taskId: Number(c.req.param('id')),
53
+ });
54
+ const enriched = await Promise.all(runs.map(async (run) => {
55
+ const current = run.status === 'running'
56
+ ? await syncRunState({
57
+ cwd: context.cwd,
58
+ homeDir: context.homeDir,
59
+ }, run.id)
60
+ : run;
61
+ return {
62
+ ...current,
63
+ output: await collectRunOutput({
64
+ cwd: context.cwd,
65
+ homeDir: context.homeDir,
66
+ }, current.id),
67
+ attachCommand: current.status === 'running'
68
+ ? await getRunAttachCommand({
69
+ cwd: context.cwd,
70
+ homeDir: context.homeDir,
71
+ }, current.id)
72
+ : null,
73
+ };
74
+ }));
75
+ return c.json(enriched);
76
+ }
77
+ catch (error) {
78
+ return c.json({ message: error.message }, 404);
79
+ }
80
+ });
81
+ app.get('/api/schedule', async (c) => {
82
+ const schedules = await listSchedules({
83
+ cwd: context.cwd,
84
+ homeDir: context.homeDir,
85
+ });
86
+ return c.json(schedules);
87
+ });
88
+ app.get('/api/graph', async (c) => {
89
+ const scope = parseScope(c.req.query('scope'));
90
+ const graph = await getTaskGraph({
91
+ cwd: context.cwd,
92
+ homeDir: context.homeDir,
93
+ scope,
94
+ });
95
+ return c.json({
96
+ nodes: graph.tasks,
97
+ edges: graph.edges,
98
+ });
99
+ });
100
+ return app;
101
+ }
102
+ function parseScope(rawScope) {
103
+ if (rawScope === 'global' || rawScope === 'all') {
104
+ return rawScope;
105
+ }
106
+ return 'project';
107
+ }
@@ -0,0 +1,142 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ import { createDashboardRoutes } from './routes.js';
5
+ import { createDashboardEventHub, toEventFiles } from './ws.js';
6
+ import { resolveStoreDir } from '../core/store.js';
7
+ export async function startDashboardServer(options) {
8
+ const app = createDashboardRoutes({
9
+ cwd: options.cwd,
10
+ homeDir: options.homeDir,
11
+ });
12
+ const eventHub = createDashboardEventHub(toEventFiles(resolveDashboardStores(options.cwd, options.homeDir)));
13
+ if (options.staticDir) {
14
+ app.get('*', async (c, next) => {
15
+ const filePath = resolveStaticPath(options.staticDir ?? '', c.req.path);
16
+ const file = await readStaticFile(filePath, options.staticDir ?? '');
17
+ if (!file) {
18
+ return next();
19
+ }
20
+ return new Response(file.body, {
21
+ headers: {
22
+ 'content-type': file.contentType,
23
+ },
24
+ });
25
+ });
26
+ }
27
+ const server = createServer(async (request, response) => {
28
+ await handleHttpRequest(app.fetch, request, response);
29
+ });
30
+ server.on('upgrade', (request, socket, head) => {
31
+ const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
32
+ if (pathname !== '/ws') {
33
+ socket.destroy();
34
+ return;
35
+ }
36
+ eventHub.handleUpgrade(request, socket, head);
37
+ });
38
+ await new Promise((resolve, reject) => {
39
+ server.once('error', reject);
40
+ server.listen(options.port ?? 3777, '127.0.0.1', () => {
41
+ server.off('error', reject);
42
+ resolve();
43
+ });
44
+ });
45
+ await eventHub.start();
46
+ const address = server.address();
47
+ if (!address || typeof address === 'string') {
48
+ throw new Error('Unable to determine dashboard server port');
49
+ }
50
+ return {
51
+ port: address.port,
52
+ async stop() {
53
+ await eventHub.stop();
54
+ await closeServer(server);
55
+ },
56
+ };
57
+ }
58
+ function resolveDashboardStores(cwd, homeDir) {
59
+ return [
60
+ resolveStoreDir({ cwd, homeDir, scope: 'project' }),
61
+ resolveStoreDir({ cwd, homeDir, scope: 'global' }),
62
+ ];
63
+ }
64
+ async function handleHttpRequest(fetchHandler, request, response) {
65
+ const origin = `http://${request.headers.host ?? '127.0.0.1'}`;
66
+ const url = new URL(request.url ?? '/', origin);
67
+ const headers = new Headers();
68
+ for (const [key, value] of Object.entries(request.headers)) {
69
+ if (Array.isArray(value)) {
70
+ for (const entry of value) {
71
+ headers.append(key, entry);
72
+ }
73
+ }
74
+ else if (value !== undefined) {
75
+ headers.set(key, value);
76
+ }
77
+ }
78
+ const body = request.method === 'GET' || request.method === 'HEAD'
79
+ ? undefined
80
+ : request;
81
+ const init = {
82
+ method: request.method,
83
+ headers,
84
+ body,
85
+ duplex: 'half',
86
+ };
87
+ const honoResponse = await fetchHandler(new Request(url, init));
88
+ response.statusCode = honoResponse.status;
89
+ honoResponse.headers.forEach((value, key) => {
90
+ response.setHeader(key, value);
91
+ });
92
+ const payload = Buffer.from(await honoResponse.arrayBuffer());
93
+ response.end(payload);
94
+ }
95
+ async function readStaticFile(filePath, staticDir) {
96
+ try {
97
+ const targetPath = filePath.startsWith(staticDir) ? filePath : join(staticDir, 'index.html');
98
+ const body = await readFile(targetPath);
99
+ return {
100
+ body,
101
+ contentType: contentTypeFor(targetPath),
102
+ };
103
+ }
104
+ catch (error) {
105
+ if (error.code === 'ENOENT') {
106
+ return null;
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ function resolveStaticPath(staticDir, pathname) {
112
+ if (pathname === '/') {
113
+ return join(staticDir, 'index.html');
114
+ }
115
+ return join(staticDir, pathname.replace(/^\/+/, ''));
116
+ }
117
+ function contentTypeFor(filePath) {
118
+ if (filePath.endsWith('.html')) {
119
+ return 'text/html; charset=utf-8';
120
+ }
121
+ if (filePath.endsWith('.js')) {
122
+ return 'text/javascript; charset=utf-8';
123
+ }
124
+ if (filePath.endsWith('.css')) {
125
+ return 'text/css; charset=utf-8';
126
+ }
127
+ if (extname(filePath) === '.json') {
128
+ return 'application/json; charset=utf-8';
129
+ }
130
+ return 'application/octet-stream';
131
+ }
132
+ async function closeServer(server) {
133
+ await new Promise((resolve, reject) => {
134
+ server.close((error) => {
135
+ if (error) {
136
+ reject(error);
137
+ return;
138
+ }
139
+ resolve();
140
+ });
141
+ });
142
+ }
@@ -0,0 +1,75 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import chokidar from 'chokidar';
4
+ import { WebSocketServer } from 'ws';
5
+ import { eventSchema } from '../types/index.js';
6
+ export function createDashboardEventHub(eventFiles) {
7
+ const sockets = new Set();
8
+ const watchers = chokidar.watch(eventFiles, {
9
+ ignoreInitial: false,
10
+ awaitWriteFinish: {
11
+ stabilityThreshold: 50,
12
+ pollInterval: 10,
13
+ },
14
+ });
15
+ const wsServer = new WebSocketServer({ noServer: true });
16
+ const processedLines = new Map();
17
+ wsServer.on('connection', (socket) => {
18
+ sockets.add(socket);
19
+ socket.on('close', () => {
20
+ sockets.delete(socket);
21
+ });
22
+ });
23
+ const processFile = async (filePath) => {
24
+ try {
25
+ const raw = await readFile(filePath, 'utf8');
26
+ const lines = raw.split('\n').filter(Boolean);
27
+ const offset = processedLines.get(filePath) ?? 0;
28
+ const freshLines = lines.slice(offset);
29
+ processedLines.set(filePath, lines.length);
30
+ for (const line of freshLines) {
31
+ broadcast(eventSchema.parse(JSON.parse(line)));
32
+ }
33
+ }
34
+ catch (error) {
35
+ if (error.code !== 'ENOENT') {
36
+ throw error;
37
+ }
38
+ }
39
+ };
40
+ const broadcast = (event) => {
41
+ const payload = JSON.stringify(event);
42
+ for (const socket of sockets) {
43
+ if (socket.readyState === socket.OPEN) {
44
+ socket.send(payload);
45
+ }
46
+ }
47
+ };
48
+ return {
49
+ async start() {
50
+ watchers.on('add', (filePath) => {
51
+ void processFile(filePath);
52
+ });
53
+ watchers.on('change', (filePath) => {
54
+ void processFile(filePath);
55
+ });
56
+ await Promise.all(eventFiles.map(async (filePath) => processFile(filePath)));
57
+ },
58
+ async stop() {
59
+ for (const socket of sockets) {
60
+ socket.close();
61
+ }
62
+ await watchers.close();
63
+ wsServer.close();
64
+ },
65
+ broadcast,
66
+ handleUpgrade(request, socket, head) {
67
+ wsServer.handleUpgrade(request, socket, head, (client) => {
68
+ wsServer.emit('connection', client, request);
69
+ });
70
+ },
71
+ };
72
+ }
73
+ export function toEventFiles(storeDirs) {
74
+ return [...new Set(storeDirs.map((storeDir) => join(storeDir, 'events.ndjson')))];
75
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ export const capabilitySupportSchema = z.enum([
3
+ 'worktree',
4
+ 'mcp',
5
+ 'subagents',
6
+ 'sandbox',
7
+ ]);
8
+ export const agentToolSchema = z.enum(['claude', 'codex']);
9
+ export const runStatusSchema = z.enum(['pending', 'running', 'completed', 'failed', 'paused']);
10
+ export const capabilityReportSchema = z.object({
11
+ available: z.boolean(),
12
+ supports: z.array(capabilitySupportSchema),
13
+ version: z.string().nullable().optional(),
14
+ });
15
+ export const runSchema = z.object({
16
+ id: z.string().min(1),
17
+ taskId: z.number().int().positive(),
18
+ tool: agentToolSchema,
19
+ status: runStatusSchema,
20
+ tmuxSession: z.string().min(1),
21
+ startedAt: z.string().datetime({ offset: true }),
22
+ completedAt: z.string().datetime({ offset: true }).nullable(),
23
+ exitCode: z.number().int().nullable(),
24
+ directory: z.string().min(1),
25
+ scope: z.enum(['project', 'global']),
26
+ runDir: z.string().min(1),
27
+ stdoutPath: z.string().min(1),
28
+ exitCodePath: z.string().min(1),
29
+ promptPath: z.string().min(1),
30
+ });
@@ -0,0 +1,87 @@
1
+ import { z } from 'zod';
2
+ export { agentToolSchema, capabilityReportSchema, capabilitySupportSchema, runSchema, runStatusSchema, } from './adapter.js';
3
+ export const taskLevelSchema = z.enum(['L1', 'L2', 'L3']);
4
+ export const taskStatusSchema = z.enum([
5
+ 'pending',
6
+ 'running',
7
+ 'blocked',
8
+ 'waiting-review',
9
+ 'completed',
10
+ 'failed',
11
+ 'paused',
12
+ ]);
13
+ export const assigneeSchema = z.enum(['claude', 'codex', 'human', 'auto']);
14
+ export const taskScopeSchema = z.enum(['project', 'global']);
15
+ export const estimatedEffortSchema = z.enum(['low', 'medium', 'high']);
16
+ export const taskSchema = z.object({
17
+ id: z.number().int().positive(),
18
+ title: z.string().min(1),
19
+ level: taskLevelSchema,
20
+ status: taskStatusSchema,
21
+ deps: z.array(z.number().int().positive()),
22
+ blocks: z.array(z.number().int().positive()),
23
+ assignee: assigneeSchema,
24
+ scope: taskScopeSchema,
25
+ project: z.string().nullable().optional(),
26
+ created: z.string().datetime({ offset: true }),
27
+ updated: z.string().datetime({ offset: true }),
28
+ tags: z.array(z.string()),
29
+ checkpoint: z.unknown().nullable(),
30
+ estimatedEffort: estimatedEffortSchema,
31
+ body: z.string(),
32
+ });
33
+ export const taskFrontmatterSchema = taskSchema.omit({ body: true });
34
+ export const indexTaskRecordSchema = z.object({
35
+ level: taskLevelSchema,
36
+ status: taskStatusSchema,
37
+ deps: z.array(z.number().int().positive()),
38
+ file: z.string().min(1),
39
+ scope: taskScopeSchema,
40
+ });
41
+ export const dagEdgeSchema = z.object({
42
+ from: z.string().min(1),
43
+ to: z.string().min(1),
44
+ type: z.literal('depends-on').default('depends-on'),
45
+ });
46
+ export const indexFileSchema = z.object({
47
+ version: z.number().int().positive(),
48
+ nextId: z.number().int().positive(),
49
+ tasks: z.record(z.string(), indexTaskRecordSchema),
50
+ dag: z.object({
51
+ edges: z.array(dagEdgeSchema),
52
+ }),
53
+ lastUpdated: z.string().datetime({ offset: true }),
54
+ });
55
+ export const scheduleSchema = z.object({
56
+ name: z.string().min(1),
57
+ cron: z.string().min(1),
58
+ command: z.string().min(1),
59
+ enabled: z.boolean(),
60
+ lastTriggered: z.string().datetime({ offset: true }).nullable(),
61
+ });
62
+ export const eventTypeSchema = z.enum([
63
+ 'task.created',
64
+ 'task.updated',
65
+ 'task.status_changed',
66
+ 'run.started',
67
+ 'run.completed',
68
+ 'run.failed',
69
+ 'schedule.triggered',
70
+ 'task.dependency_unlocked',
71
+ ]);
72
+ export const eventSchema = z.object({
73
+ ts: z.string().datetime({ offset: true }),
74
+ type: eventTypeSchema,
75
+ taskId: z.number().int().positive().optional(),
76
+ actor: z.string().min(1),
77
+ from: taskStatusSchema.optional(),
78
+ to: taskStatusSchema.optional(),
79
+ detail: z.record(z.string(), z.unknown()).default({}),
80
+ });
81
+ export const configSchema = z.object({
82
+ defaultAgent: assigneeSchema.exclude(['human']),
83
+ notify: z.object({
84
+ enabled: z.boolean(),
85
+ }),
86
+ dashboardPort: z.number().int().positive().default(3777),
87
+ });
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "harness-async",
3
+ "version": "0.1.0",
4
+ "description": "Agent-first CLI for async human-agent collaboration",
5
+ "type": "module",
6
+ "bin": {
7
+ "ha": "dist/src/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "dev": "tsx watch src/cli/index.ts",
16
+ "dev:dashboard": "vite --config src/dashboard/ui/vite.config.ts",
17
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
18
+ "build": "npm run clean && tsc -p tsconfig.build.json && npm run build:dashboard",
19
+ "build:dashboard": "vite build --config src/dashboard/ui/vite.config.ts",
20
+ "lint": "eslint . --ext .ts",
21
+ "test": "vitest run",
22
+ "test:e2e": "vitest run tests/e2e",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "prepublishOnly": "npm run build && npm test"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "dependencies": {
30
+ "@hono/node-server": "^1.19.4",
31
+ "@xyflow/react": "^12.8.5",
32
+ "chalk": "^5.4.1",
33
+ "chokidar": "^4.0.3",
34
+ "commander": "^14.0.0",
35
+ "dagre": "^0.8.5",
36
+ "gray-matter": "^4.0.3",
37
+ "hono": "^4.9.8",
38
+ "node-cron": "^4.2.1",
39
+ "node-notifier": "^10.0.1",
40
+ "proper-lockfile": "^4.1.2",
41
+ "react": "^19.1.1",
42
+ "react-dom": "^19.1.1",
43
+ "ws": "^8.18.3",
44
+ "zod": "^4.1.5"
45
+ },
46
+ "devDependencies": {
47
+ "@types/react": "^19.1.12",
48
+ "@types/react-dom": "^19.1.9",
49
+ "@types/node": "^24.5.2",
50
+ "@types/proper-lockfile": "^4.1.4",
51
+ "@types/ws": "^8.18.1",
52
+ "@vitejs/plugin-react": "^5.0.4",
53
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
54
+ "@typescript-eslint/parser": "^7.18.0",
55
+ "autoprefixer": "^10.4.21",
56
+ "eslint": "^8.57.1",
57
+ "postcss": "^8.5.6",
58
+ "prettier": "^3.6.2",
59
+ "tailwindcss": "^3.4.17",
60
+ "tsx": "^4.20.5",
61
+ "typescript": "^5.9.2",
62
+ "vite": "^7.1.5",
63
+ "vitest": "^3.2.4"
64
+ }
65
+ }