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.
Files changed (88) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/README.zh-TW.md +99 -0
  5. package/bin/cdb.ts +60 -0
  6. package/bun.lock +1612 -0
  7. package/bunfig.toml +4 -0
  8. package/components.json +20 -0
  9. package/next.config.ts +19 -0
  10. package/package.json +62 -0
  11. package/postcss.config.mjs +9 -0
  12. package/prompts/pm-system.md +61 -0
  13. package/prompts/rd-system.md +68 -0
  14. package/prompts/sec-system.md +93 -0
  15. package/prompts/test-system.md +71 -0
  16. package/prompts/ui-system.md +72 -0
  17. package/server.ts +118 -0
  18. package/sql.js.d.ts +33 -0
  19. package/src/__tests__/api/usage/route.test.ts +193 -0
  20. package/src/__tests__/components/layout/TopNav.test.tsx +155 -0
  21. package/src/__tests__/components/layout/UsageIndicator.test.tsx +503 -0
  22. package/src/__tests__/hooks/useUsage.test.tsx +174 -0
  23. package/src/__tests__/lib/usage/get-token.test.ts +117 -0
  24. package/src/__tests__/react-sanity.test.tsx +14 -0
  25. package/src/__tests__/sanity.test.ts +7 -0
  26. package/src/__tests__/setup.ts +1 -0
  27. package/src/app/api/health/route.ts +8 -0
  28. package/src/app/api/usage/route.ts +86 -0
  29. package/src/app/api/workflows/[id]/route.ts +17 -0
  30. package/src/app/api/workflows/route.ts +14 -0
  31. package/src/app/globals.css +74 -0
  32. package/src/app/history/page.tsx +15 -0
  33. package/src/app/layout.tsx +24 -0
  34. package/src/app/page.tsx +112 -0
  35. package/src/components/agent/AgentCard.tsx +117 -0
  36. package/src/components/agent/AgentCardGrid.tsx +14 -0
  37. package/src/components/agent/AgentOutput.tsx +87 -0
  38. package/src/components/agent/AgentStatusBadge.tsx +20 -0
  39. package/src/components/events/EventLog.tsx +65 -0
  40. package/src/components/events/EventLogItem.tsx +39 -0
  41. package/src/components/history/HistoryTable.tsx +105 -0
  42. package/src/components/layout/DashboardShell.tsx +12 -0
  43. package/src/components/layout/TopNav.tsx +86 -0
  44. package/src/components/layout/UsageIndicator.tsx +163 -0
  45. package/src/components/pipeline/PipelineBar.tsx +59 -0
  46. package/src/components/pipeline/PipelineNode.tsx +55 -0
  47. package/src/components/terminal/TerminalPanel.tsx +138 -0
  48. package/src/components/terminal/XTermRenderer.tsx +129 -0
  49. package/src/components/ui/badge.tsx +37 -0
  50. package/src/components/ui/button.tsx +55 -0
  51. package/src/components/ui/card.tsx +80 -0
  52. package/src/components/ui/input.tsx +26 -0
  53. package/src/components/ui/scroll-area.tsx +52 -0
  54. package/src/components/ui/separator.tsx +31 -0
  55. package/src/components/ui/textarea.tsx +25 -0
  56. package/src/components/ui/tooltip.tsx +73 -0
  57. package/src/components/workflow/WorkflowLauncher.tsx +102 -0
  58. package/src/hooks/useAgentStream.ts +27 -0
  59. package/src/hooks/useAutoScroll.ts +24 -0
  60. package/src/hooks/useUsage.ts +66 -0
  61. package/src/hooks/useWebSocket.ts +289 -0
  62. package/src/lib/agents/prompts.ts +341 -0
  63. package/src/lib/db/connection.ts +263 -0
  64. package/src/lib/db/queries.ts +257 -0
  65. package/src/lib/db/schema.ts +39 -0
  66. package/src/lib/output-buffer.ts +41 -0
  67. package/src/lib/terminal/pty-manager.ts +106 -0
  68. package/src/lib/usage/get-token.ts +48 -0
  69. package/src/lib/utils.ts +6 -0
  70. package/src/lib/websocket/connection-manager.ts +71 -0
  71. package/src/lib/websocket/protocol.ts +90 -0
  72. package/src/lib/websocket/server.ts +231 -0
  73. package/src/lib/workflow/agent-runner.ts +254 -0
  74. package/src/lib/workflow/context-builder.ts +62 -0
  75. package/src/lib/workflow/engine.ts +310 -0
  76. package/src/lib/workflow/pipeline.ts +28 -0
  77. package/src/lib/workflow/types.ts +111 -0
  78. package/src/stores/agentStore.ts +152 -0
  79. package/src/stores/eventStore.ts +35 -0
  80. package/src/stores/terminalStore.ts +20 -0
  81. package/src/stores/uiStore.ts +35 -0
  82. package/src/stores/workflowStore.ts +57 -0
  83. package/src/types/css.d.ts +4 -0
  84. package/src/types/index.ts +12 -0
  85. package/tailwind.config.ts +65 -0
  86. package/tsconfig.json +25 -0
  87. package/tsconfig.server.json +21 -0
  88. package/vitest.config.ts +25 -0
@@ -0,0 +1,231 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import { IncomingMessage } from 'http';
3
+ import { ConnectionManager } from './connection-manager.ts';
4
+ import { WorkflowEngine } from '../workflow/engine.ts';
5
+ import { PtyManager } from '../terminal/pty-manager.ts';
6
+
7
+ export function setupWebSocketHandlers(
8
+ wss: WebSocketServer,
9
+ connectionManager: ConnectionManager,
10
+ engine: WorkflowEngine,
11
+ ptyManager: PtyManager,
12
+ projectPath: string
13
+ ) {
14
+ // Wire workflow engine events to WebSocket broadcasts
15
+ engine.on('workflow:created', (workflowId: string, title: string) => {
16
+ connectionManager.broadcastAll({
17
+ type: 'workflow:created',
18
+ payload: { workflowId, title },
19
+ });
20
+ });
21
+
22
+ engine.on('workflow:completed', (workflowId: string) => {
23
+ connectionManager.broadcastToWorkflow(workflowId, {
24
+ type: 'workflow:completed',
25
+ payload: { workflowId },
26
+ });
27
+ });
28
+
29
+ engine.on('workflow:failed', (workflowId: string, error: string) => {
30
+ connectionManager.broadcastToWorkflow(workflowId, {
31
+ type: 'workflow:failed',
32
+ payload: { workflowId, error },
33
+ });
34
+ });
35
+
36
+ engine.on('workflow:paused', (workflowId: string) => {
37
+ connectionManager.broadcastToWorkflow(workflowId, {
38
+ type: 'workflow:paused',
39
+ payload: { workflowId },
40
+ });
41
+ });
42
+
43
+ engine.on('workflow:cancelled', (workflowId: string) => {
44
+ connectionManager.broadcastToWorkflow(workflowId, {
45
+ type: 'workflow:cancelled',
46
+ payload: { workflowId },
47
+ });
48
+ });
49
+
50
+ engine.on('step:started', (workflowId: string, stepId: string, role: string) => {
51
+ connectionManager.broadcastToWorkflow(workflowId, {
52
+ type: 'step:started',
53
+ payload: { workflowId, stepId, role },
54
+ });
55
+ });
56
+
57
+ engine.on('step:stream', (workflowId: string, stepId: string, role: string, chunk: string) => {
58
+ connectionManager.broadcastToWorkflow(workflowId, {
59
+ type: 'step:stream',
60
+ payload: { workflowId, stepId, role, chunk },
61
+ });
62
+ });
63
+
64
+ engine.on('step:completed', (workflowId: string, stepId: string, role: string, output: string, durationMs: number, tokensIn?: number, tokensOut?: number) => {
65
+ connectionManager.broadcastToWorkflow(workflowId, {
66
+ type: 'step:completed',
67
+ payload: { workflowId, stepId, role, output, durationMs, tokensIn, tokensOut },
68
+ });
69
+ });
70
+
71
+ engine.on('step:failed', (workflowId: string, stepId: string, role: string, error: string) => {
72
+ connectionManager.broadcastToWorkflow(workflowId, {
73
+ type: 'step:failed',
74
+ payload: { workflowId, stepId, role, error },
75
+ });
76
+ });
77
+
78
+ engine.on('step:activity', (workflowId: string, stepId: string, role: string, activity: any) => {
79
+ connectionManager.broadcastToWorkflow(workflowId, {
80
+ type: 'step:activity',
81
+ payload: { workflowId, stepId, role, activity },
82
+ });
83
+ });
84
+
85
+ engine.on('step:retry', (workflowId: string, stepId: string, role: string, attempt: number, maxRetries: number, reason: string) => {
86
+ connectionManager.broadcastToWorkflow(workflowId, {
87
+ type: 'step:retry',
88
+ payload: { workflowId, stepId, role, attempt, maxRetries, reason },
89
+ });
90
+ });
91
+
92
+ // Handle new WebSocket connections
93
+ wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
94
+ const clientId = connectionManager.addClient(ws);
95
+ console.log(`[WS] Client connected: ${clientId}`);
96
+
97
+ ws.on('message', (raw: Buffer) => {
98
+ try {
99
+ const msg = JSON.parse(raw.toString());
100
+ handleClientMessage(clientId, ws, msg, connectionManager, engine, ptyManager, projectPath);
101
+ } catch (err) {
102
+ console.error('[WS] Invalid message:', err);
103
+ }
104
+ });
105
+
106
+ ws.on('close', () => {
107
+ console.log(`[WS] Client disconnected: ${clientId}`);
108
+ connectionManager.removeClient(clientId);
109
+ });
110
+
111
+ ws.on('error', (err) => {
112
+ console.error(`[WS] Client ${clientId} error:`, err.message);
113
+ });
114
+ });
115
+ }
116
+
117
+ function handleClientMessage(
118
+ clientId: string,
119
+ ws: WebSocket,
120
+ msg: any,
121
+ connectionManager: ConnectionManager,
122
+ engine: WorkflowEngine,
123
+ ptyManager: PtyManager,
124
+ defaultProjectPath: string
125
+ ) {
126
+ switch (msg.type) {
127
+ case 'ping':
128
+ ws.send(JSON.stringify({ type: 'pong' }));
129
+ break;
130
+
131
+ case 'workflow:start': {
132
+ const { prompt, projectPath } = msg.payload || {};
133
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
134
+ ws.send(JSON.stringify({
135
+ type: 'workflow:failed',
136
+ payload: { workflowId: null, error: 'Prompt is required' },
137
+ }));
138
+ break;
139
+ }
140
+ const path = projectPath || defaultProjectPath;
141
+ engine.startWorkflow(prompt, path).then((workflowId) => {
142
+ connectionManager.subscribeToWorkflow(clientId, workflowId);
143
+ }).catch((err: any) => {
144
+ console.error('[WS] Failed to start workflow:', err);
145
+ ws.send(JSON.stringify({
146
+ type: 'workflow:failed',
147
+ payload: { workflowId: null, error: err.message },
148
+ }));
149
+ });
150
+ break;
151
+ }
152
+
153
+ case 'workflow:subscribe': {
154
+ const { workflowId } = msg.payload || {};
155
+ if (workflowId) {
156
+ connectionManager.subscribeToWorkflow(clientId, workflowId);
157
+ }
158
+ break;
159
+ }
160
+
161
+ case 'workflow:pause': {
162
+ const { workflowId } = msg.payload || {};
163
+ if (workflowId) engine.pauseWorkflow(workflowId);
164
+ break;
165
+ }
166
+
167
+ case 'workflow:resume': {
168
+ const { workflowId } = msg.payload || {};
169
+ if (workflowId) engine.resumeWorkflow(workflowId);
170
+ break;
171
+ }
172
+
173
+ case 'workflow:cancel': {
174
+ const { workflowId } = msg.payload || {};
175
+ if (workflowId) engine.cancelWorkflow(workflowId);
176
+ break;
177
+ }
178
+
179
+ case 'terminal:create': {
180
+ const { projectPath } = msg.payload || {};
181
+ const path = projectPath || defaultProjectPath;
182
+ try {
183
+ const terminalId = ptyManager.create(path, (data: string) => {
184
+ connectionManager.sendTo(clientId, {
185
+ type: 'terminal:output',
186
+ payload: { terminalId, data },
187
+ });
188
+ });
189
+ connectionManager.sendTo(clientId, {
190
+ type: 'terminal:created',
191
+ payload: { terminalId },
192
+ });
193
+ } catch (err: any) {
194
+ console.error('[WS] Failed to create terminal:', err.message);
195
+ connectionManager.sendTo(clientId, {
196
+ type: 'terminal:error',
197
+ payload: { error: err.message },
198
+ });
199
+ }
200
+ break;
201
+ }
202
+
203
+ case 'terminal:input': {
204
+ const { terminalId, data } = msg.payload || {};
205
+ if (terminalId && data != null) {
206
+ ptyManager.write(terminalId, data);
207
+ }
208
+ break;
209
+ }
210
+
211
+ case 'terminal:resize': {
212
+ const { terminalId, cols, rows } = msg.payload || {};
213
+ if (terminalId && cols && rows) {
214
+ ptyManager.resize(terminalId, cols, rows);
215
+ }
216
+ break;
217
+ }
218
+
219
+ case 'terminal:close': {
220
+ const { terminalId } = msg.payload || {};
221
+ if (terminalId) {
222
+ ptyManager.kill(terminalId);
223
+ connectionManager.sendTo(clientId, {
224
+ type: 'terminal:closed',
225
+ payload: { terminalId },
226
+ });
227
+ }
228
+ break;
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,254 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import { EventEmitter } from 'events';
3
+ import { type AgentRole, type AgentActivity, AGENT_CONFIG } from './types.ts';
4
+
5
+ export interface AgentRunnerEvents {
6
+ stream: (chunk: string) => void;
7
+ activity: (activity: AgentActivity) => void;
8
+ result: (output: string, tokensIn?: number, tokensOut?: number) => void;
9
+ error: (error: string) => void;
10
+ }
11
+
12
+ export class AgentRunner extends EventEmitter {
13
+ private process: ChildProcess | null = null;
14
+ private killed = false;
15
+ private output = '';
16
+ private resultEmitted = false;
17
+ private role: AgentRole;
18
+ private activityTimer: ReturnType<typeof setTimeout> | null = null;
19
+ private hardTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
20
+
21
+ constructor(role: AgentRole) {
22
+ super();
23
+ this.role = role;
24
+ // Prevent Node from throwing on unhandled 'error' events
25
+ this.on('error', () => {});
26
+ }
27
+
28
+ async run(prompt: string, systemPrompt: string, projectPath: string): Promise<void> {
29
+ const config = AGENT_CONFIG[this.role];
30
+
31
+ return new Promise<void>((resolve, reject) => {
32
+ // Guard against double resolve/reject
33
+ let settled = false;
34
+ const settle = (fn: () => void) => {
35
+ if (settled) return;
36
+ settled = true;
37
+ this.clearAllTimers();
38
+ fn();
39
+ };
40
+
41
+ const args = [
42
+ '-p', prompt,
43
+ '--verbose',
44
+ '--output-format', 'stream-json',
45
+ '--max-turns', '50',
46
+ '--dangerously-skip-permissions',
47
+ '--include-partial-messages',
48
+ ];
49
+
50
+ if (systemPrompt) {
51
+ args.push('--system-prompt', systemPrompt);
52
+ }
53
+
54
+ // Restrict tools based on role
55
+ const allowedTools = config.tools;
56
+ if (allowedTools.length > 0) {
57
+ args.push('--allowedTools', allowedTools.join(','));
58
+ }
59
+
60
+ this.process = spawn('claude', args, {
61
+ cwd: projectPath,
62
+ env: { ...process.env },
63
+ stdio: ['pipe', 'pipe', 'pipe'],
64
+ });
65
+
66
+ // Close stdin so `claude -p` receives EOF and starts processing
67
+ this.process.stdin?.end();
68
+
69
+ // Hard timeout: 2x configured timeout, absolute upper bound
70
+ this.hardTimeoutTimer = setTimeout(() => {
71
+ this.kill();
72
+ const err = `Agent ${config.label} hard timeout after ${(config.timeoutMs * 2) / 1000}s`;
73
+ this.emit('error', err);
74
+ settle(() => reject(new Error(err)));
75
+ }, config.timeoutMs * 2);
76
+
77
+ // Activity-based timeout: resets on every stream event
78
+ this.resetActivityTimeout(settle, reject);
79
+
80
+ let buffer = '';
81
+
82
+ this.process.stdout?.on('data', (data: Buffer) => {
83
+ buffer += data.toString();
84
+ const lines = buffer.split('\n');
85
+ buffer = lines.pop() || '';
86
+
87
+ for (const line of lines) {
88
+ if (!line.trim()) continue;
89
+ try {
90
+ const event = JSON.parse(line);
91
+ this.handleStreamEvent(event);
92
+ } catch {
93
+ // Non-JSON output, treat as raw text — still counts as activity
94
+ this.resetActivityTimeout();
95
+ this.output += line + '\n';
96
+ this.emit('stream', line + '\n');
97
+ }
98
+ }
99
+ });
100
+
101
+ this.process.stderr?.on('data', (data: Buffer) => {
102
+ const text = data.toString();
103
+ console.error(`[${config.label}] stderr:`, text.trim());
104
+ });
105
+
106
+ this.process.on('close', (code) => {
107
+ if (this.killed) {
108
+ settle(() => reject(new Error('Agent was killed')));
109
+ return;
110
+ }
111
+ if (code === 0) {
112
+ if (!this.resultEmitted) {
113
+ this.emit('result', this.output);
114
+ }
115
+ settle(() => resolve());
116
+ } else {
117
+ const err = `Agent ${config.label} exited with code ${code}`;
118
+ this.emit('error', err);
119
+ settle(() => reject(new Error(err)));
120
+ }
121
+ });
122
+
123
+ this.process.on('error', (err) => {
124
+ this.emit('error', err.message);
125
+ settle(() => reject(err));
126
+ });
127
+ });
128
+ }
129
+
130
+ private handleStreamEvent(event: any) {
131
+ // Any JSON event from CLI means the agent is alive — reset inactivity timer
132
+ this.resetActivityTimeout();
133
+
134
+ switch (event.type) {
135
+ case 'stream_event': {
136
+ // Unwrap the inner event from stream_event wrapper
137
+ const inner = event.event;
138
+ if (!inner) break;
139
+
140
+ switch (inner.type) {
141
+ case 'content_block_start': {
142
+ const block = inner.content_block;
143
+ if (block?.type === 'thinking') {
144
+ this.emit('activity', { kind: 'thinking' });
145
+ } else if (block?.type === 'tool_use') {
146
+ this.emit('activity', { kind: 'tool_use', toolName: block.name || 'unknown' });
147
+ } else if (block?.type === 'text') {
148
+ this.emit('activity', { kind: 'text' });
149
+ }
150
+ break;
151
+ }
152
+ case 'content_block_delta': {
153
+ if (inner.delta?.type === 'text_delta' && inner.delta.text) {
154
+ this.output += inner.delta.text;
155
+ this.emit('stream', inner.delta.text);
156
+ }
157
+ break;
158
+ }
159
+ case 'content_block_stop':
160
+ this.emit('activity', { kind: 'idle' });
161
+ break;
162
+ }
163
+ break;
164
+ }
165
+
166
+ case 'assistant':
167
+ // With --include-partial-messages, text is already captured via stream_event.
168
+ // This handler serves as fallback for edge cases where streaming missed content.
169
+ if (event.message?.content) {
170
+ const fullText = event.message.content
171
+ .filter((b: any) => b.type === 'text')
172
+ .map((b: any) => b.text)
173
+ .join('');
174
+
175
+ if (fullText && !this.output.includes(fullText.slice(0, 100))) {
176
+ // Streaming missed this content — add separator and append
177
+ if (this.output.length > 0) {
178
+ this.output += '\n\n';
179
+ this.emit('stream', '\n\n');
180
+ }
181
+ this.output += fullText;
182
+ this.emit('stream', fullText);
183
+ } else if (this.output.length > 0) {
184
+ // Content already captured via streaming — just add turn separator
185
+ this.output += '\n\n';
186
+ this.emit('stream', '\n\n');
187
+ }
188
+ }
189
+ break;
190
+
191
+ case 'result':
192
+ if (event.result && !this.resultEmitted) {
193
+ this.resultEmitted = true;
194
+ const tokensIn = event.usage?.input_tokens;
195
+ const tokensOut = event.usage?.output_tokens;
196
+ if (!this.output) {
197
+ this.output = typeof event.result === 'string'
198
+ ? event.result
199
+ : JSON.stringify(event.result);
200
+ }
201
+ this.emit('result', this.output, tokensIn, tokensOut);
202
+ }
203
+ break;
204
+ }
205
+ }
206
+
207
+ kill() {
208
+ this.killed = true;
209
+ this.clearAllTimers();
210
+ if (this.process && !this.process.killed) {
211
+ this.process.kill('SIGTERM');
212
+ // Force kill after 5s if process hasn't exited
213
+ setTimeout(() => {
214
+ if (this.process && this.process.exitCode === null) {
215
+ this.process.kill('SIGKILL');
216
+ }
217
+ }, 5000);
218
+ }
219
+ }
220
+
221
+ private settleRef: { settle: (fn: () => void) => void; reject: (err: Error) => void } | null = null;
222
+
223
+ private resetActivityTimeout(settle?: (fn: () => void) => void, reject?: (err: Error) => void) {
224
+ // Store settle/reject on first call (from run()), reuse on subsequent calls (from handleStreamEvent)
225
+ if (settle && reject) {
226
+ this.settleRef = { settle, reject };
227
+ }
228
+ if (this.activityTimer) clearTimeout(this.activityTimer);
229
+ const config = AGENT_CONFIG[this.role];
230
+ this.activityTimer = setTimeout(() => {
231
+ this.kill();
232
+ const err = `Agent ${config.label} timed out after ${config.timeoutMs / 1000}s of inactivity`;
233
+ this.emit('error', err);
234
+ if (this.settleRef) {
235
+ this.settleRef.settle(() => this.settleRef!.reject(new Error(err)));
236
+ }
237
+ }, config.timeoutMs);
238
+ }
239
+
240
+ private clearAllTimers() {
241
+ if (this.activityTimer) {
242
+ clearTimeout(this.activityTimer);
243
+ this.activityTimer = null;
244
+ }
245
+ if (this.hardTimeoutTimer) {
246
+ clearTimeout(this.hardTimeoutTimer);
247
+ this.hardTimeoutTimer = null;
248
+ }
249
+ }
250
+
251
+ getOutput(): string {
252
+ return this.output;
253
+ }
254
+ }
@@ -0,0 +1,62 @@
1
+ import { type AgentRole, AGENT_CONFIG, PIPELINE_STAGES, getStageForRole } from './types.ts';
2
+
3
+ export interface AgentContext {
4
+ role: AgentRole;
5
+ output: string;
6
+ }
7
+
8
+ /**
9
+ * Build the prompt for an agent, including context from previous agents.
10
+ */
11
+ export function buildAgentPrompt(
12
+ role: AgentRole,
13
+ userPrompt: string,
14
+ previousOutputs: AgentContext[],
15
+ projectPath: string
16
+ ): string {
17
+ const parts: string[] = [];
18
+
19
+ parts.push(`# Project Path\n${projectPath}\n`);
20
+ parts.push(`# User Request\n${userPrompt}\n`);
21
+
22
+ if (previousOutputs.length > 0) {
23
+ parts.push('# Previous Agent Outputs\n');
24
+ for (const ctx of previousOutputs) {
25
+ const config = AGENT_CONFIG[ctx.role];
26
+ parts.push(`## ${config.label} Agent Output\n${ctx.output}\n`);
27
+ }
28
+ }
29
+
30
+ const config = AGENT_CONFIG[role];
31
+ const currentStage = getStageForRole(role);
32
+
33
+ // Peers running in parallel within the same stage
34
+ const peers = currentStage.roles
35
+ .filter((r) => r !== role)
36
+ .map((r) => AGENT_CONFIG[r].label);
37
+
38
+ // Agents in later stages
39
+ const downstreamStages = PIPELINE_STAGES.filter(
40
+ (s) => s.index > currentStage.index
41
+ );
42
+ const downstreamLabels = downstreamStages.flatMap((s) =>
43
+ s.roles.map((r) => AGENT_CONFIG[r].label)
44
+ );
45
+
46
+ parts.push(`# Your Role: ${config.label} Agent`);
47
+ if (peers.length > 0) {
48
+ parts.push(`You are running IN PARALLEL with: ${peers.join(', ')}`);
49
+ }
50
+ if (downstreamLabels.length > 0) {
51
+ parts.push(
52
+ `After this stage completes, the following agents will run: ${downstreamLabels.join(' → ')}`
53
+ );
54
+ } else {
55
+ parts.push('You are in the final stage of the pipeline.');
56
+ }
57
+
58
+ parts.push('# Important: Output Language');
59
+ parts.push('Respond in the SAME language as the "# User Request" section above. Do NOT switch to English just because the template is in English.');
60
+
61
+ return parts.join('\n\n');
62
+ }