claude-dashboard 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.
- package/.claude/settings.local.json +10 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/README.zh-TW.md +99 -0
- package/bin/cdb.ts +60 -0
- package/bun.lock +1612 -0
- package/bunfig.toml +4 -0
- package/components.json +20 -0
- package/next.config.ts +19 -0
- package/package.json +62 -0
- package/postcss.config.mjs +9 -0
- package/prompts/pm-system.md +61 -0
- package/prompts/rd-system.md +68 -0
- package/prompts/sec-system.md +93 -0
- package/prompts/test-system.md +71 -0
- package/prompts/ui-system.md +72 -0
- package/server.ts +118 -0
- package/sql.js.d.ts +33 -0
- package/src/__tests__/api/usage/route.test.ts +193 -0
- package/src/__tests__/components/layout/TopNav.test.tsx +155 -0
- package/src/__tests__/components/layout/UsageIndicator.test.tsx +503 -0
- package/src/__tests__/hooks/useUsage.test.tsx +174 -0
- package/src/__tests__/lib/usage/get-token.test.ts +117 -0
- package/src/__tests__/react-sanity.test.tsx +14 -0
- package/src/__tests__/sanity.test.ts +7 -0
- package/src/__tests__/setup.ts +1 -0
- package/src/app/api/health/route.ts +8 -0
- package/src/app/api/usage/route.ts +86 -0
- package/src/app/api/workflows/[id]/route.ts +17 -0
- package/src/app/api/workflows/route.ts +14 -0
- package/src/app/globals.css +74 -0
- package/src/app/history/page.tsx +15 -0
- package/src/app/layout.tsx +24 -0
- package/src/app/page.tsx +112 -0
- package/src/components/agent/AgentCard.tsx +117 -0
- package/src/components/agent/AgentCardGrid.tsx +14 -0
- package/src/components/agent/AgentOutput.tsx +87 -0
- package/src/components/agent/AgentStatusBadge.tsx +20 -0
- package/src/components/events/EventLog.tsx +65 -0
- package/src/components/events/EventLogItem.tsx +39 -0
- package/src/components/history/HistoryTable.tsx +105 -0
- package/src/components/layout/DashboardShell.tsx +12 -0
- package/src/components/layout/TopNav.tsx +86 -0
- package/src/components/layout/UsageIndicator.tsx +163 -0
- package/src/components/pipeline/PipelineBar.tsx +59 -0
- package/src/components/pipeline/PipelineNode.tsx +55 -0
- package/src/components/terminal/TerminalPanel.tsx +138 -0
- package/src/components/terminal/XTermRenderer.tsx +129 -0
- package/src/components/ui/badge.tsx +37 -0
- package/src/components/ui/button.tsx +55 -0
- package/src/components/ui/card.tsx +80 -0
- package/src/components/ui/input.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +52 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/textarea.tsx +25 -0
- package/src/components/ui/tooltip.tsx +73 -0
- package/src/components/workflow/WorkflowLauncher.tsx +102 -0
- package/src/hooks/useAgentStream.ts +27 -0
- package/src/hooks/useAutoScroll.ts +24 -0
- package/src/hooks/useUsage.ts +66 -0
- package/src/hooks/useWebSocket.ts +289 -0
- package/src/lib/agents/prompts.ts +341 -0
- package/src/lib/db/connection.ts +263 -0
- package/src/lib/db/queries.ts +257 -0
- package/src/lib/db/schema.ts +39 -0
- package/src/lib/output-buffer.ts +41 -0
- package/src/lib/terminal/pty-manager.ts +106 -0
- package/src/lib/usage/get-token.ts +48 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/websocket/connection-manager.ts +71 -0
- package/src/lib/websocket/protocol.ts +90 -0
- package/src/lib/websocket/server.ts +231 -0
- package/src/lib/workflow/agent-runner.ts +254 -0
- package/src/lib/workflow/context-builder.ts +62 -0
- package/src/lib/workflow/engine.ts +310 -0
- package/src/lib/workflow/pipeline.ts +28 -0
- package/src/lib/workflow/types.ts +111 -0
- package/src/stores/agentStore.ts +152 -0
- package/src/stores/eventStore.ts +35 -0
- package/src/stores/terminalStore.ts +20 -0
- package/src/stores/uiStore.ts +35 -0
- package/src/stores/workflowStore.ts +57 -0
- package/src/types/css.d.ts +4 -0
- package/src/types/index.ts +12 -0
- package/tailwind.config.ts +65 -0
- package/tsconfig.json +25 -0
- package/tsconfig.server.json +21 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { getDb } from './connection.ts';
|
|
3
|
+
import {
|
|
4
|
+
AGENT_ORDER,
|
|
5
|
+
type AgentRole,
|
|
6
|
+
type AgentStep,
|
|
7
|
+
type StepStatus,
|
|
8
|
+
type Workflow,
|
|
9
|
+
type WorkflowStatus,
|
|
10
|
+
} from '../workflow/types.ts';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Internal helpers: snake_case <-> camelCase row mapping
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
interface WorkflowRow {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
user_prompt: string;
|
|
20
|
+
status: string;
|
|
21
|
+
current_step_index: number;
|
|
22
|
+
project_path: string;
|
|
23
|
+
created_at: string;
|
|
24
|
+
updated_at: string;
|
|
25
|
+
completed_at: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AgentStepRow {
|
|
29
|
+
id: string;
|
|
30
|
+
workflow_id: string;
|
|
31
|
+
role: string;
|
|
32
|
+
status: string;
|
|
33
|
+
prompt: string;
|
|
34
|
+
output: string;
|
|
35
|
+
error: string | null;
|
|
36
|
+
retry_count: number;
|
|
37
|
+
duration_ms: number | null;
|
|
38
|
+
tokens_in: number | null;
|
|
39
|
+
tokens_out: number | null;
|
|
40
|
+
started_at: string | null;
|
|
41
|
+
completed_at: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rowToWorkflow(row: WorkflowRow): Workflow {
|
|
45
|
+
return {
|
|
46
|
+
id: row.id,
|
|
47
|
+
title: row.title,
|
|
48
|
+
userPrompt: row.user_prompt,
|
|
49
|
+
status: row.status as WorkflowStatus,
|
|
50
|
+
currentStepIndex: row.current_step_index,
|
|
51
|
+
projectPath: row.project_path,
|
|
52
|
+
createdAt: row.created_at,
|
|
53
|
+
updatedAt: row.updated_at,
|
|
54
|
+
completedAt: row.completed_at,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rowToStep(row: AgentStepRow): AgentStep {
|
|
59
|
+
return {
|
|
60
|
+
id: row.id,
|
|
61
|
+
workflowId: row.workflow_id,
|
|
62
|
+
role: row.role as AgentRole,
|
|
63
|
+
status: row.status as StepStatus,
|
|
64
|
+
prompt: row.prompt,
|
|
65
|
+
output: row.output,
|
|
66
|
+
error: row.error,
|
|
67
|
+
retryCount: row.retry_count,
|
|
68
|
+
durationMs: row.duration_ms,
|
|
69
|
+
tokensIn: row.tokens_in,
|
|
70
|
+
tokensOut: row.tokens_out,
|
|
71
|
+
startedAt: row.started_at,
|
|
72
|
+
completedAt: row.completed_at,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Workflow CRUD
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a new workflow together with its five agent-step rows (one per role
|
|
82
|
+
* in `AGENT_ORDER`). Everything runs inside a single transaction so that the
|
|
83
|
+
* workflow and its steps are always created atomically.
|
|
84
|
+
*/
|
|
85
|
+
export function createWorkflow(
|
|
86
|
+
id: string,
|
|
87
|
+
title: string,
|
|
88
|
+
userPrompt: string,
|
|
89
|
+
projectPath: string,
|
|
90
|
+
): Workflow {
|
|
91
|
+
const db = getDb();
|
|
92
|
+
|
|
93
|
+
const insertWorkflow = db.prepare(`
|
|
94
|
+
INSERT INTO workflows (id, title, user_prompt, status, current_step_index, project_path)
|
|
95
|
+
VALUES (@id, @title, @userPrompt, 'pending', 0, @projectPath)
|
|
96
|
+
`);
|
|
97
|
+
|
|
98
|
+
const insertStep = db.prepare(`
|
|
99
|
+
INSERT INTO agent_steps (id, workflow_id, role, status)
|
|
100
|
+
VALUES (@id, @workflowId, @role, 'pending')
|
|
101
|
+
`);
|
|
102
|
+
|
|
103
|
+
const txn = db.transaction(() => {
|
|
104
|
+
insertWorkflow.run({ id, title, userPrompt, projectPath });
|
|
105
|
+
|
|
106
|
+
for (const role of AGENT_ORDER) {
|
|
107
|
+
insertStep.run({
|
|
108
|
+
id: uuidv4(),
|
|
109
|
+
workflowId: id,
|
|
110
|
+
role,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
txn();
|
|
116
|
+
|
|
117
|
+
return getWorkflow(id)!;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Retrieve a single workflow by id, or `null` if not found.
|
|
122
|
+
*/
|
|
123
|
+
export function getWorkflow(id: string): Workflow | null {
|
|
124
|
+
const db = getDb();
|
|
125
|
+
const stmt = db.prepare('SELECT * FROM workflows WHERE id = ?');
|
|
126
|
+
const row = stmt.get(id) as WorkflowRow | undefined;
|
|
127
|
+
return row ? rowToWorkflow(row) : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* List workflows ordered by creation time (newest first).
|
|
132
|
+
*/
|
|
133
|
+
export function listWorkflows(limit = 50, offset = 0): Workflow[] {
|
|
134
|
+
const db = getDb();
|
|
135
|
+
const stmt = db.prepare(
|
|
136
|
+
'SELECT * FROM workflows ORDER BY created_at DESC LIMIT ? OFFSET ?',
|
|
137
|
+
);
|
|
138
|
+
const rows = stmt.all(limit, offset) as WorkflowRow[];
|
|
139
|
+
return rows.map(rowToWorkflow);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Update a workflow's status (and optionally the current step index).
|
|
144
|
+
*
|
|
145
|
+
* `updated_at` is always bumped. When the status is a terminal state
|
|
146
|
+
* (`completed`, `failed`, `cancelled`) `completed_at` is also set.
|
|
147
|
+
*/
|
|
148
|
+
export function updateWorkflowStatus(
|
|
149
|
+
id: string,
|
|
150
|
+
status: WorkflowStatus,
|
|
151
|
+
currentStepIndex?: number,
|
|
152
|
+
): void {
|
|
153
|
+
const db = getDb();
|
|
154
|
+
|
|
155
|
+
const isTerminal =
|
|
156
|
+
status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
157
|
+
|
|
158
|
+
if (currentStepIndex !== undefined) {
|
|
159
|
+
const stmt = db.prepare(`
|
|
160
|
+
UPDATE workflows
|
|
161
|
+
SET status = @status,
|
|
162
|
+
current_step_index = @currentStepIndex,
|
|
163
|
+
updated_at = datetime('now'),
|
|
164
|
+
completed_at = CASE WHEN @isTerminal THEN datetime('now') ELSE completed_at END
|
|
165
|
+
WHERE id = @id
|
|
166
|
+
`);
|
|
167
|
+
stmt.run({ id, status, currentStepIndex, isTerminal: isTerminal ? 1 : 0 });
|
|
168
|
+
} else {
|
|
169
|
+
const stmt = db.prepare(`
|
|
170
|
+
UPDATE workflows
|
|
171
|
+
SET status = @status,
|
|
172
|
+
updated_at = datetime('now'),
|
|
173
|
+
completed_at = CASE WHEN @isTerminal THEN datetime('now') ELSE completed_at END
|
|
174
|
+
WHERE id = @id
|
|
175
|
+
`);
|
|
176
|
+
stmt.run({ id, status, isTerminal: isTerminal ? 1 : 0 });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// AgentStep CRUD
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Return all steps for a given workflow, ordered by the canonical agent order.
|
|
186
|
+
*
|
|
187
|
+
* We use a CASE expression so the rows always come back in the same sequence
|
|
188
|
+
* as `AGENT_ORDER` regardless of insertion order.
|
|
189
|
+
*/
|
|
190
|
+
export function getStepsForWorkflow(workflowId: string): AgentStep[] {
|
|
191
|
+
const db = getDb();
|
|
192
|
+
const stmt = db.prepare(`
|
|
193
|
+
SELECT *
|
|
194
|
+
FROM agent_steps
|
|
195
|
+
WHERE workflow_id = ?
|
|
196
|
+
ORDER BY CASE role
|
|
197
|
+
WHEN 'pm' THEN 0
|
|
198
|
+
WHEN 'rd' THEN 1
|
|
199
|
+
WHEN 'ui' THEN 2
|
|
200
|
+
WHEN 'test' THEN 3
|
|
201
|
+
WHEN 'sec' THEN 4
|
|
202
|
+
ELSE 5
|
|
203
|
+
END
|
|
204
|
+
`);
|
|
205
|
+
const rows = stmt.all(workflowId) as AgentStepRow[];
|
|
206
|
+
return rows.map(rowToStep);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Partial update for an agent step. Only the fields present in `updates`
|
|
211
|
+
* will be written; all others are left untouched.
|
|
212
|
+
*/
|
|
213
|
+
export interface StepUpdateFields {
|
|
214
|
+
status?: StepStatus;
|
|
215
|
+
prompt?: string;
|
|
216
|
+
output?: string;
|
|
217
|
+
error?: string | null;
|
|
218
|
+
retryCount?: number;
|
|
219
|
+
durationMs?: number | null;
|
|
220
|
+
tokensIn?: number | null;
|
|
221
|
+
tokensOut?: number | null;
|
|
222
|
+
startedAt?: string | null;
|
|
223
|
+
completedAt?: string | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function updateStepStatus(id: string, updates: StepUpdateFields): void {
|
|
227
|
+
const db = getDb();
|
|
228
|
+
|
|
229
|
+
// Build SET clause dynamically based on provided keys.
|
|
230
|
+
const columnMap: Record<keyof StepUpdateFields, string> = {
|
|
231
|
+
status: 'status',
|
|
232
|
+
prompt: 'prompt',
|
|
233
|
+
output: 'output',
|
|
234
|
+
error: 'error',
|
|
235
|
+
retryCount: 'retry_count',
|
|
236
|
+
durationMs: 'duration_ms',
|
|
237
|
+
tokensIn: 'tokens_in',
|
|
238
|
+
tokensOut: 'tokens_out',
|
|
239
|
+
startedAt: 'started_at',
|
|
240
|
+
completedAt: 'completed_at',
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const setClauses: string[] = [];
|
|
244
|
+
const params: Record<string, unknown> = { id };
|
|
245
|
+
|
|
246
|
+
for (const [key, column] of Object.entries(columnMap)) {
|
|
247
|
+
if (key in updates) {
|
|
248
|
+
setClauses.push(`${column} = @${key}`);
|
|
249
|
+
params[key] = (updates as Record<string, unknown>)[key] ?? null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (setClauses.length === 0) return;
|
|
254
|
+
|
|
255
|
+
const sql = `UPDATE agent_steps SET ${setClauses.join(', ')} WHERE id = @id`;
|
|
256
|
+
db.prepare(sql).run(params);
|
|
257
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite schema for the Claude Dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Tables:
|
|
5
|
+
* - workflows : top-level workflow records
|
|
6
|
+
* - agent_steps : one row per agent role per workflow (5 per workflow)
|
|
7
|
+
*/
|
|
8
|
+
export const SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS workflows (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
title TEXT NOT NULL,
|
|
12
|
+
user_prompt TEXT NOT NULL,
|
|
13
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
14
|
+
current_step_index INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
project_path TEXT NOT NULL,
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
17
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
18
|
+
completed_at TEXT
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS agent_steps (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
workflow_id TEXT NOT NULL,
|
|
24
|
+
role TEXT NOT NULL,
|
|
25
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
26
|
+
prompt TEXT NOT NULL DEFAULT '',
|
|
27
|
+
output TEXT NOT NULL DEFAULT '',
|
|
28
|
+
error TEXT,
|
|
29
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
duration_ms INTEGER,
|
|
31
|
+
tokens_in INTEGER,
|
|
32
|
+
tokens_out INTEGER,
|
|
33
|
+
started_at TEXT,
|
|
34
|
+
completed_at TEXT,
|
|
35
|
+
FOREIGN KEY (workflow_id) REFERENCES workflows(id)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_agent_steps_workflow ON agent_steps(workflow_id);
|
|
39
|
+
`;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutputBuffer: Batches high-frequency token streams into 50ms flushes
|
|
3
|
+
*/
|
|
4
|
+
export class OutputBuffer {
|
|
5
|
+
private buffer: string[] = [];
|
|
6
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
7
|
+
private readonly flushIntervalMs: number;
|
|
8
|
+
private readonly onFlush: (chunks: string[]) => void;
|
|
9
|
+
|
|
10
|
+
constructor(onFlush: (chunks: string[]) => void, flushIntervalMs = 50) {
|
|
11
|
+
this.onFlush = onFlush;
|
|
12
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
push(chunk: string) {
|
|
16
|
+
this.buffer.push(chunk);
|
|
17
|
+
if (!this.timer) {
|
|
18
|
+
this.timer = setTimeout(() => this.flush(), this.flushIntervalMs);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
flush() {
|
|
23
|
+
if (this.timer) {
|
|
24
|
+
clearTimeout(this.timer);
|
|
25
|
+
this.timer = null;
|
|
26
|
+
}
|
|
27
|
+
if (this.buffer.length > 0) {
|
|
28
|
+
const chunks = this.buffer;
|
|
29
|
+
this.buffer = [];
|
|
30
|
+
this.onFlush(chunks);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
destroy() {
|
|
35
|
+
if (this.timer) {
|
|
36
|
+
clearTimeout(this.timer);
|
|
37
|
+
this.timer = null;
|
|
38
|
+
}
|
|
39
|
+
this.buffer = [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { existsSync, chmodSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
// node-pty is optional - gracefully handle if not available
|
|
8
|
+
let pty: any;
|
|
9
|
+
try {
|
|
10
|
+
pty = require('node-pty');
|
|
11
|
+
fixSpawnHelperPermissions();
|
|
12
|
+
} catch {
|
|
13
|
+
console.warn('[PTY] node-pty not available, terminal features disabled');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fixSpawnHelperPermissions() {
|
|
17
|
+
if (process.platform === 'win32') return;
|
|
18
|
+
try {
|
|
19
|
+
const ptyPath = require.resolve('node-pty');
|
|
20
|
+
const baseDir = join(dirname(ptyPath), '..');
|
|
21
|
+
const arch = process.arch;
|
|
22
|
+
const helperPath = join(baseDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper');
|
|
23
|
+
if (existsSync(helperPath)) {
|
|
24
|
+
chmodSync(helperPath, 0o755);
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getShellPath(): string {
|
|
30
|
+
if (process.platform === 'win32') return 'powershell.exe';
|
|
31
|
+
|
|
32
|
+
const preferred = process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash';
|
|
33
|
+
if (existsSync(preferred)) return preferred;
|
|
34
|
+
return '/bin/sh';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface PtySession {
|
|
38
|
+
id: string;
|
|
39
|
+
process: any; // IPty
|
|
40
|
+
onData: (data: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class PtyManager {
|
|
44
|
+
private sessions: Map<string, PtySession> = new Map();
|
|
45
|
+
|
|
46
|
+
create(cwd: string, onData: (data: string) => void): string {
|
|
47
|
+
if (!pty) {
|
|
48
|
+
throw new Error('node-pty is not available');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const id = uuidv4();
|
|
52
|
+
const shell = getShellPath();
|
|
53
|
+
|
|
54
|
+
const proc = pty.spawn(shell, [], {
|
|
55
|
+
name: 'xterm-256color',
|
|
56
|
+
cols: 80,
|
|
57
|
+
rows: 24,
|
|
58
|
+
cwd,
|
|
59
|
+
env: { ...process.env },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
proc.onData((data: string) => {
|
|
63
|
+
onData(data);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
proc.onExit(() => {
|
|
67
|
+
this.sessions.delete(id);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this.sessions.set(id, { id, process: proc, onData });
|
|
71
|
+
return id;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
write(id: string, data: string) {
|
|
75
|
+
const session = this.sessions.get(id);
|
|
76
|
+
if (session) {
|
|
77
|
+
session.process.write(data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
resize(id: string, cols: number, rows: number) {
|
|
82
|
+
const session = this.sessions.get(id);
|
|
83
|
+
if (session) {
|
|
84
|
+
session.process.resize(cols, rows);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
kill(id: string) {
|
|
89
|
+
const session = this.sessions.get(id);
|
|
90
|
+
if (session) {
|
|
91
|
+
session.process.kill();
|
|
92
|
+
this.sessions.delete(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
killAll() {
|
|
97
|
+
for (const session of this.sessions.values()) {
|
|
98
|
+
session.process.kill();
|
|
99
|
+
}
|
|
100
|
+
this.sessions.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getSessionCount(): number {
|
|
104
|
+
return this.sessions.size;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
interface ClaudeCredentials {
|
|
7
|
+
claudeAiOauth: {
|
|
8
|
+
accessToken: string;
|
|
9
|
+
refreshToken: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* macOS: 從 Keychain 讀取
|
|
15
|
+
*/
|
|
16
|
+
function readFromKeychain(): string {
|
|
17
|
+
const raw = execSync(
|
|
18
|
+
'security find-generic-password -s "Claude Code-credentials" -w',
|
|
19
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
|
|
20
|
+
).trim();
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Windows / Linux: 從 ~/.claude/.credentials.json 讀取
|
|
26
|
+
*/
|
|
27
|
+
function readFromCredentialsFile(): string {
|
|
28
|
+
const filePath = path.join(homedir(), ".claude", ".credentials.json");
|
|
29
|
+
return readFileSync(filePath, "utf-8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getClaudeOAuthToken(): string {
|
|
33
|
+
try {
|
|
34
|
+
const raw = process.platform === "darwin"
|
|
35
|
+
? readFromKeychain()
|
|
36
|
+
: readFromCredentialsFile();
|
|
37
|
+
|
|
38
|
+
const credentials: ClaudeCredentials = JSON.parse(raw);
|
|
39
|
+
const token = credentials?.claudeAiOauth?.accessToken;
|
|
40
|
+
if (!token) {
|
|
41
|
+
throw new Error("accessToken not found in credential JSON");
|
|
42
|
+
}
|
|
43
|
+
return token;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
46
|
+
throw new Error(`Failed to read Claude Code OAuth token: ${message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
|
|
3
|
+
export interface ClientConnection {
|
|
4
|
+
ws: WebSocket;
|
|
5
|
+
id: string;
|
|
6
|
+
subscribedWorkflows: Set<string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ConnectionManager {
|
|
10
|
+
private clients: Map<string, ClientConnection> = new Map();
|
|
11
|
+
private clientCounter = 0;
|
|
12
|
+
|
|
13
|
+
addClient(ws: WebSocket): string {
|
|
14
|
+
const id = `client-${++this.clientCounter}`;
|
|
15
|
+
this.clients.set(id, {
|
|
16
|
+
ws,
|
|
17
|
+
id,
|
|
18
|
+
subscribedWorkflows: new Set(),
|
|
19
|
+
});
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
removeClient(id: string) {
|
|
24
|
+
this.clients.delete(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
subscribeToWorkflow(clientId: string, workflowId: string) {
|
|
28
|
+
const client = this.clients.get(clientId);
|
|
29
|
+
if (client) {
|
|
30
|
+
client.subscribedWorkflows.add(workflowId);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Send to all clients subscribed to a workflow
|
|
36
|
+
*/
|
|
37
|
+
broadcastToWorkflow(workflowId: string, message: object) {
|
|
38
|
+
const data = JSON.stringify(message);
|
|
39
|
+
for (const client of this.clients.values()) {
|
|
40
|
+
if (client.subscribedWorkflows.has(workflowId) && client.ws.readyState === WebSocket.OPEN) {
|
|
41
|
+
client.ws.send(data);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send to all connected clients
|
|
48
|
+
*/
|
|
49
|
+
broadcastAll(message: object) {
|
|
50
|
+
const data = JSON.stringify(message);
|
|
51
|
+
for (const client of this.clients.values()) {
|
|
52
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
53
|
+
client.ws.send(data);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Send to a specific client
|
|
60
|
+
*/
|
|
61
|
+
sendTo(clientId: string, message: object) {
|
|
62
|
+
const client = this.clients.get(clientId);
|
|
63
|
+
if (client && client.ws.readyState === WebSocket.OPEN) {
|
|
64
|
+
client.ws.send(JSON.stringify(message));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getClientCount(): number {
|
|
69
|
+
return this.clients.size;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { AgentRole, AgentActivity } from '../workflow/types.ts';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Client -> Server messages
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export type ClientMessage =
|
|
8
|
+
| { type: 'workflow:start'; payload: { prompt: string; projectPath: string } }
|
|
9
|
+
| { type: 'workflow:pause'; payload: { workflowId: string } }
|
|
10
|
+
| { type: 'workflow:resume'; payload: { workflowId: string } }
|
|
11
|
+
| { type: 'workflow:cancel'; payload: { workflowId: string } }
|
|
12
|
+
| { type: 'workflow:subscribe'; payload: { workflowId: string } }
|
|
13
|
+
| { type: 'terminal:create'; payload: { projectPath: string } }
|
|
14
|
+
| { type: 'terminal:input'; payload: { terminalId: string; data: string } }
|
|
15
|
+
| {
|
|
16
|
+
type: 'terminal:resize';
|
|
17
|
+
payload: { terminalId: string; cols: number; rows: number };
|
|
18
|
+
}
|
|
19
|
+
| { type: 'terminal:close'; payload: { terminalId: string } }
|
|
20
|
+
| { type: 'ping' };
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Server -> Client messages
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export type ServerMessage =
|
|
27
|
+
| { type: 'workflow:created'; payload: { workflowId: string; title: string } }
|
|
28
|
+
| { type: 'workflow:completed'; payload: { workflowId: string } }
|
|
29
|
+
| { type: 'workflow:failed'; payload: { workflowId: string; error: string } }
|
|
30
|
+
| { type: 'workflow:paused'; payload: { workflowId: string } }
|
|
31
|
+
| { type: 'workflow:cancelled'; payload: { workflowId: string } }
|
|
32
|
+
| {
|
|
33
|
+
type: 'step:started';
|
|
34
|
+
payload: { workflowId: string; stepId: string; role: AgentRole };
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
type: 'step:stream';
|
|
38
|
+
payload: {
|
|
39
|
+
workflowId: string;
|
|
40
|
+
stepId: string;
|
|
41
|
+
role: AgentRole;
|
|
42
|
+
chunk: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
type: 'step:completed';
|
|
47
|
+
payload: {
|
|
48
|
+
workflowId: string;
|
|
49
|
+
stepId: string;
|
|
50
|
+
role: AgentRole;
|
|
51
|
+
output: string;
|
|
52
|
+
durationMs: number;
|
|
53
|
+
tokensIn?: number;
|
|
54
|
+
tokensOut?: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
type: 'step:failed';
|
|
59
|
+
payload: {
|
|
60
|
+
workflowId: string;
|
|
61
|
+
stepId: string;
|
|
62
|
+
role: AgentRole;
|
|
63
|
+
error: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
type: 'step:activity';
|
|
68
|
+
payload: {
|
|
69
|
+
workflowId: string;
|
|
70
|
+
stepId: string;
|
|
71
|
+
role: AgentRole;
|
|
72
|
+
activity: AgentActivity;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
type: 'step:retry';
|
|
77
|
+
payload: {
|
|
78
|
+
workflowId: string;
|
|
79
|
+
stepId: string;
|
|
80
|
+
role: AgentRole;
|
|
81
|
+
attempt: number;
|
|
82
|
+
maxRetries: number;
|
|
83
|
+
reason: string;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
| { type: 'terminal:created'; payload: { terminalId: string } }
|
|
87
|
+
| { type: 'terminal:output'; payload: { terminalId: string; data: string } }
|
|
88
|
+
| { type: 'terminal:error'; payload: { error: string } }
|
|
89
|
+
| { type: 'terminal:closed'; payload: { terminalId: string } }
|
|
90
|
+
| { type: 'pong' };
|