@aion0/forge 0.4.16 → 0.5.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 (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. package/start.sh +7 -0
@@ -0,0 +1,479 @@
1
+ /**
2
+ * CLI Backend — executes agent steps by spawning CLI tools headless.
3
+ *
4
+ * Supports subscription accounts (no API key needed).
5
+ * Each step = one `claude -p "..."` call.
6
+ * Multi-step context via --resume (Claude) or prompt injection (others).
7
+ */
8
+
9
+ import { spawn, type ChildProcess } from 'node:child_process';
10
+ import { createRequire } from 'node:module';
11
+ import { getAgent } from '@/lib/agents';
12
+ import type { AgentBackend, AgentStep, StepExecutionParams, StepExecutionResult, Artifact } from '../types';
13
+ import type { TaskLogEntry } from '@/src/types';
14
+
15
+ const esmRequire = createRequire(import.meta.url);
16
+
17
+ // ─── Stream-JSON parser (reused from task-manager pattern) ──
18
+
19
+ function parseStreamJson(parsed: any): TaskLogEntry[] {
20
+ const entries: TaskLogEntry[] = [];
21
+ const ts = new Date().toISOString();
22
+
23
+ if (parsed.type === 'system' && parsed.subtype === 'init') {
24
+ entries.push({ type: 'system', subtype: 'init', content: `Model: ${parsed.model || 'unknown'}`, timestamp: ts });
25
+ return entries;
26
+ }
27
+
28
+ if (parsed.type === 'assistant' && parsed.message?.content) {
29
+ for (const block of parsed.message.content) {
30
+ if (block.type === 'text' && block.text) {
31
+ entries.push({ type: 'assistant', subtype: 'text', content: block.text, timestamp: ts });
32
+ } else if (block.type === 'tool_use') {
33
+ entries.push({
34
+ type: 'assistant',
35
+ subtype: 'tool_use',
36
+ content: typeof block.input === 'string' ? block.input : JSON.stringify(block.input || {}),
37
+ tool: block.name,
38
+ timestamp: ts,
39
+ });
40
+ } else if (block.type === 'tool_result') {
41
+ entries.push({
42
+ type: 'assistant',
43
+ subtype: 'tool_result',
44
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || ''),
45
+ timestamp: ts,
46
+ });
47
+ }
48
+ }
49
+ return entries;
50
+ }
51
+
52
+ if (parsed.type === 'result') {
53
+ entries.push({
54
+ type: 'result',
55
+ subtype: parsed.subtype || 'success',
56
+ content: typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result || ''),
57
+ timestamp: ts,
58
+ });
59
+ return entries;
60
+ }
61
+
62
+ // Ignore rate limit events
63
+ if (parsed.type === 'rate_limit_event') return entries;
64
+
65
+ // Unknown type — log raw
66
+ entries.push({ type: 'assistant', subtype: parsed.type || 'unknown', content: JSON.stringify(parsed), timestamp: ts });
67
+ return entries;
68
+ }
69
+
70
+ // ─── Artifact detection from tool_use events ─────────────
71
+
72
+ const WRITE_TOOL_NAMES = new Set(['Write', 'write_to_file', 'Edit', 'create_file', 'write_file']);
73
+
74
+ function detectArtifacts(parsed: any): Artifact[] {
75
+ const artifacts: Artifact[] = [];
76
+ if (parsed.type !== 'assistant' || !parsed.message?.content) return artifacts;
77
+
78
+ for (const block of parsed.message.content) {
79
+ if (block.type === 'tool_use' && WRITE_TOOL_NAMES.has(block.name)) {
80
+ const path = block.input?.file_path || block.input?.path || block.input?.filename;
81
+ if (path) {
82
+ artifacts.push({ type: 'file', path, summary: `Written by ${block.name}` });
83
+ }
84
+ }
85
+ }
86
+ return artifacts;
87
+ }
88
+
89
+ // ─── CLI Backend class ───────────────────────────────────
90
+
91
+ export class CliBackend implements AgentBackend {
92
+ private child: ChildProcess | null = null;
93
+ private sessionId: string | undefined;
94
+ /** Callback to persist sessionId back to agent state */
95
+ onSessionId?: (id: string) => void;
96
+
97
+ constructor(initialSessionId?: string) {
98
+ this.sessionId = initialSessionId;
99
+ }
100
+
101
+ async executeStep(params: StepExecutionParams): Promise<StepExecutionResult> {
102
+ const { config, step, history, projectPath, upstreamContext, onLog, abortSignal, workspaceId } = params;
103
+ const agentId = config.agentId || 'claude';
104
+
105
+ let adapter;
106
+ try {
107
+ adapter = getAgent(agentId);
108
+ } catch {
109
+ throw new Error(`Agent "${agentId}" not found or not installed`);
110
+ }
111
+
112
+ // Build prompt with context
113
+ const prompt = this.buildStepPrompt(step, history, upstreamContext);
114
+
115
+ // Use adapter to build spawn command (same as task-manager)
116
+ // Model priority: workspace config > profile config > adapter default
117
+ const effectiveModel = config.model || (adapter.config as any).model;
118
+ const spawnOpts = adapter.buildTaskSpawn({
119
+ projectPath,
120
+ prompt,
121
+ model: effectiveModel,
122
+ conversationId: this.sessionId,
123
+ skipPermissions: true,
124
+ outputFormat: adapter.config.capabilities?.supportsStreamJson ? 'stream-json' : undefined,
125
+ });
126
+
127
+ onLog?.({
128
+ type: 'system',
129
+ subtype: 'init',
130
+ content: `Step "${step.label}" — ${agentId}${config.model ? `/${config.model}` : ''}${this.sessionId ? ' (resume)' : ''}`,
131
+ timestamp: new Date().toISOString(),
132
+ });
133
+
134
+ return new Promise<StepExecutionResult>((resolve, reject) => {
135
+ // Merge env: process env → adapter spawn env → profile env → workspace context
136
+ const profileEnv = (adapter.config as any).env || {};
137
+ const env = {
138
+ ...process.env,
139
+ ...(spawnOpts.env || {}),
140
+ ...profileEnv,
141
+ // Inject workspace context so forge skills can use them
142
+ FORGE_AGENT_ID: config.id,
143
+ FORGE_WORKSPACE_ID: workspaceId || '',
144
+ FORGE_PORT: String(process.env.PORT || 8403),
145
+ };
146
+ delete env.CLAUDECODE;
147
+
148
+ // Check if agent needs TTY (same logic as task-manager)
149
+ const needsTTY = adapter.config.capabilities?.requiresTTY
150
+ || agentId === 'codex' || (adapter.config as any).base === 'codex';
151
+
152
+ if (needsTTY) {
153
+ this.executePTY(spawnOpts, projectPath, env, onLog, abortSignal, resolve, reject);
154
+ return;
155
+ }
156
+
157
+ this.child = spawn(spawnOpts.cmd, spawnOpts.args, {
158
+ cwd: projectPath,
159
+ env,
160
+ stdio: ['pipe', 'pipe', 'pipe'],
161
+ });
162
+ this.child.stdin?.end();
163
+
164
+ let buffer = '';
165
+ let resultText = '';
166
+ let sessionId = '';
167
+ const artifacts: Artifact[] = [];
168
+ let inputTokens = 0;
169
+ let outputTokens = 0;
170
+
171
+ // Handle abort signal
172
+ const onAbort = () => {
173
+ this.child?.kill('SIGTERM');
174
+ };
175
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
176
+
177
+ // Fatal error detection pattern — only used for stderr (stdout is structured JSON)
178
+ const FATAL_PATTERN = /usage limit|rate limit|hit your.*limit|upgrade to (plus|pro|max)|exceeded.*monthly|you've been rate limited|api key.*invalid|insufficient.*quota|billing.*not.*active/i;
179
+ let fatalDetected = false;
180
+
181
+ this.child.stdout?.on('data', (data: Buffer) => {
182
+ const raw = data.toString();
183
+ // Fatal detection on stdout — only on non-JSON lines (skip tool results, user messages)
184
+ // JSON lines start with { and contain structured data from claude CLI
185
+ if (!fatalDetected && FATAL_PATTERN.test(raw)) {
186
+ // Check each line individually — only flag if it's NOT inside a JSON payload
187
+ const nonJsonLines = raw.split('\n').filter(l => {
188
+ const trimmed = l.trim();
189
+ return trimmed && !trimmed.startsWith('{') && !trimmed.startsWith('"') && !trimmed.includes('tool_use_id');
190
+ });
191
+ const fatalLine = nonJsonLines.find(l => FATAL_PATTERN.test(l));
192
+ if (fatalLine) {
193
+ fatalDetected = true;
194
+ console.log(`[cli-backend] Fatal error detected: ${fatalLine.trim().slice(0, 100)}`);
195
+ onLog?.({ type: 'system', subtype: 'error', content: fatalLine.trim().slice(0, 200), timestamp: new Date().toISOString() });
196
+ this.child?.kill('SIGTERM');
197
+ return;
198
+ }
199
+ }
200
+ buffer += raw;
201
+ const lines = buffer.split('\n');
202
+ buffer = lines.pop() || '';
203
+
204
+ for (const line of lines) {
205
+ if (!line.trim()) continue;
206
+ try {
207
+ const parsed = JSON.parse(line);
208
+
209
+ // Emit log entries
210
+ const entries = parseStreamJson(parsed);
211
+ for (const entry of entries) {
212
+ onLog?.(entry);
213
+ }
214
+
215
+ // Track session ID for multi-step resume
216
+ if (parsed.session_id) sessionId = parsed.session_id;
217
+
218
+ // Track result
219
+ if (parsed.type === 'result') {
220
+ resultText = typeof parsed.result === 'string'
221
+ ? parsed.result
222
+ : JSON.stringify(parsed.result || '');
223
+ if (parsed.total_cost_usd) {
224
+ // Cost tracking if available
225
+ }
226
+ }
227
+
228
+ // Track usage
229
+ if (parsed.usage) {
230
+ inputTokens += parsed.usage.input_tokens || 0;
231
+ outputTokens += parsed.usage.output_tokens || 0;
232
+ }
233
+
234
+ // Detect file write artifacts
235
+ artifacts.push(...detectArtifacts(parsed));
236
+
237
+ } catch {
238
+ // Non-JSON line — emit as raw text log
239
+ if (line.trim()) {
240
+ onLog?.({
241
+ type: 'assistant',
242
+ subtype: 'text',
243
+ content: line,
244
+ timestamp: new Date().toISOString(),
245
+ });
246
+ }
247
+ }
248
+ }
249
+ });
250
+
251
+ this.child.stderr?.on('data', (data: Buffer) => {
252
+ const text = data.toString().trim();
253
+ // Also check stderr for fatal errors
254
+ if (!fatalDetected && FATAL_PATTERN.test(text)) {
255
+ fatalDetected = true;
256
+ console.log(`[cli-backend] Fatal error in stderr: ${text.slice(0, 100)}`);
257
+ this.child?.kill('SIGTERM');
258
+ }
259
+ if (text) {
260
+ onLog?.({
261
+ type: 'system',
262
+ subtype: 'error',
263
+ content: text,
264
+ timestamp: new Date().toISOString(),
265
+ });
266
+ }
267
+ });
268
+
269
+ this.child.on('error', (err) => {
270
+ abortSignal?.removeEventListener('abort', onAbort);
271
+ this.child = null;
272
+ reject(err);
273
+ });
274
+
275
+ this.child.on('exit', (code) => {
276
+ abortSignal?.removeEventListener('abort', onAbort);
277
+
278
+ // Flush remaining buffer
279
+ if (buffer.trim()) {
280
+ try {
281
+ const parsed = JSON.parse(buffer);
282
+ const entries = parseStreamJson(parsed);
283
+ for (const entry of entries) onLog?.(entry);
284
+ if (parsed.type === 'result') {
285
+ resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result || '');
286
+ }
287
+ if (parsed.session_id) sessionId = parsed.session_id;
288
+ artifacts.push(...detectArtifacts(parsed));
289
+ } catch {
290
+ if (buffer.trim()) {
291
+ onLog?.({ type: 'assistant', subtype: 'text', content: buffer.trim(), timestamp: new Date().toISOString() });
292
+ }
293
+ }
294
+ }
295
+
296
+ // Persist session ID for multi-step resume
297
+ this.sessionId = sessionId || this.sessionId;
298
+ if (this.sessionId && this.onSessionId) this.onSessionId(this.sessionId);
299
+ this.child = null;
300
+
301
+ // Check for error patterns even if exit code is 0
302
+ const KNOWN_ERRORS = /usage limit|rate limit|upgrade to|authentication failed|api key.*invalid/i;
303
+ const errorInOutput = resultText.split('\n').find(l => KNOWN_ERRORS.test(l))?.trim();
304
+
305
+ if (errorInOutput) {
306
+ reject(new Error(errorInOutput.slice(0, 200)));
307
+ } else if (code === 0 || code === null) {
308
+ resolve({
309
+ response: resultText,
310
+ artifacts,
311
+ sessionId: this.sessionId,
312
+ inputTokens,
313
+ outputTokens,
314
+ });
315
+ } else if (abortSignal?.aborted || code === 143 || code === 130) {
316
+ // 143=SIGTERM, 130=SIGINT — normal shutdown, not an error
317
+ reject(new Error('Aborted'));
318
+ } else {
319
+ reject(new Error(`CLI exited with code ${code}`));
320
+ }
321
+ });
322
+ });
323
+ }
324
+
325
+ abort(): void {
326
+ this.child?.kill('SIGTERM');
327
+ }
328
+
329
+ /**
330
+ * Build the prompt for a step, injecting history context.
331
+ * If resuming (sessionId exists), the CLI already has conversation context.
332
+ * Otherwise, prepend a summary of prior steps.
333
+ */
334
+ /** Execute step using node-pty for agents that require a TTY (e.g., codex) */
335
+ private executePTY(
336
+ spawnOpts: { cmd: string; args: string[] },
337
+ projectPath: string,
338
+ env: Record<string, string | undefined>,
339
+ onLog: StepExecutionParams['onLog'],
340
+ abortSignal: AbortSignal | undefined,
341
+ resolve: (r: StepExecutionResult) => void,
342
+ reject: (e: Error) => void,
343
+ ): void {
344
+ try {
345
+ const pty = esmRequire('node-pty');
346
+ const stripAnsi = (s: string) => s
347
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
348
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
349
+ .replace(/\x1b[()][0-9A-B]/g, '')
350
+ .replace(/\x1b[=>]/g, '')
351
+ .replace(/\r/g, '')
352
+ .replace(/\x07/g, '');
353
+
354
+ const ptyProcess = pty.spawn(spawnOpts.cmd, spawnOpts.args, {
355
+ name: 'xterm-256color',
356
+ cols: 120, rows: 40,
357
+ cwd: projectPath,
358
+ env,
359
+ });
360
+
361
+ let resultText = '';
362
+ let ptyBytes = 0;
363
+ let idleTimer: any = null;
364
+ const PTY_IDLE_MS = 15000;
365
+
366
+ const onAbort = () => { try { ptyProcess.kill(); } catch {} };
367
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
368
+
369
+ // Noise filter: skip spinner fragments, partial redraws, and short garbage
370
+ const NOISE_PATTERNS = /^(W|Wo|Wor|Work|Worki|Workin|Working|orking|rking|king|ing|ng|g|•|[0-9]+s?|[0-9]+m [0-9]+s|›.*|─+|╭.*|│.*|╰.*|\[K|\[0m|;[0-9;m]+|\s*)$/;
371
+ const isNoise = (line: string) => {
372
+ const t = line.trim();
373
+ return !t || t.length < 3 || NOISE_PATTERNS.test(t) || /^[•\s]*$/.test(t);
374
+ };
375
+
376
+ let lineBuf = '';
377
+
378
+ ptyProcess.onData((data: string) => {
379
+ const clean = stripAnsi(data);
380
+ ptyBytes += clean.length;
381
+ resultText += clean;
382
+ if (resultText.length > 50000) resultText = resultText.slice(-25000);
383
+
384
+ // Buffer lines and only emit meaningful content
385
+ lineBuf += clean;
386
+ const lines = lineBuf.split('\n');
387
+ lineBuf = lines.pop() || '';
388
+
389
+ for (const line of lines) {
390
+ if (!isNoise(line)) {
391
+ onLog?.({ type: 'assistant', subtype: 'text', content: line.trim(), timestamp: new Date().toISOString() });
392
+ }
393
+ }
394
+
395
+ // Detect fatal errors in real-time — kill immediately instead of waiting for idle
396
+ if (/usage limit|rate limit|hit your.*limit|upgrade to (plus|pro)/i.test(clean)) {
397
+ console.log(`[cli-backend] Detected usage limit — killing PTY immediately`);
398
+ onLog?.({ type: 'system', subtype: 'error', content: 'Agent hit usage limit', timestamp: new Date().toISOString() });
399
+ if (idleTimer) clearTimeout(idleTimer);
400
+ try { ptyProcess.kill(); } catch {}
401
+ return;
402
+ }
403
+
404
+ // Idle timer: kill after 15s of silence (interactive agents don't exit on their own)
405
+ if (idleTimer) clearTimeout(idleTimer);
406
+ if (ptyBytes > 500) {
407
+ idleTimer = setTimeout(() => { try { ptyProcess.kill(); } catch {} }, PTY_IDLE_MS);
408
+ }
409
+ });
410
+
411
+ ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
412
+ if (idleTimer) clearTimeout(idleTimer);
413
+ abortSignal?.removeEventListener('abort', onAbort);
414
+
415
+ // Flush remaining buffer
416
+ if (lineBuf.trim() && !isNoise(lineBuf)) {
417
+ onLog?.({ type: 'assistant', subtype: 'text', content: lineBuf.trim(), timestamp: new Date().toISOString() });
418
+ }
419
+
420
+ // Detect error patterns in output (rate limit, auth failure, etc.)
421
+ const ERROR_PATTERNS = [
422
+ /usage limit/i,
423
+ /rate limit/i,
424
+ /upgrade to/i,
425
+ /authentication failed/i,
426
+ /api key/i,
427
+ /permission denied/i,
428
+ /error:.*fatal/i,
429
+ ];
430
+ const errorMatch = ERROR_PATTERNS.find(p => p.test(resultText));
431
+ if (errorMatch) {
432
+ // Extract the error line
433
+ const errorLine = resultText.split('\n').find(l => errorMatch.test(l))?.trim() || 'Agent execution failed';
434
+ reject(new Error(errorLine.slice(0, 200)));
435
+ return;
436
+ }
437
+
438
+ const meaningful = resultText.split('\n').filter(l => !isNoise(l)).join('\n');
439
+ resolve({
440
+ response: meaningful.slice(-2000) || resultText.slice(-500),
441
+ artifacts: [],
442
+ inputTokens: 0,
443
+ outputTokens: 0,
444
+ });
445
+ });
446
+ } catch (err: any) {
447
+ reject(new Error(`PTY spawn failed: ${err.message}`));
448
+ }
449
+ }
450
+
451
+ private buildStepPrompt(step: AgentStep, history: TaskLogEntry[], upstreamContext?: string): string {
452
+ let prompt = step.prompt;
453
+
454
+ // If resuming with session, Claude already has conversation context — skip history injection
455
+ if (!this.sessionId && history.length > 0) {
456
+ // Only inject last 3 step results (not full history) to save tokens
457
+ const MAX_HISTORY_STEPS = 3;
458
+ const stepResults = history
459
+ .filter(m => m.type === 'result' && m.subtype === 'step_complete')
460
+ .slice(-MAX_HISTORY_STEPS);
461
+
462
+ if (stepResults.length > 0) {
463
+ const contextSummary = stepResults
464
+ .map((m, i) => `Step ${i + 1}: ${m.content.slice(0, 500)}${m.content.length > 500 ? '...' : ''}`)
465
+ .join('\n\n');
466
+ prompt = `## Context from previous steps (last ${stepResults.length}):\n${contextSummary}\n\n---\n\n## Current task:\n${prompt}`;
467
+ }
468
+ }
469
+
470
+ if (upstreamContext) {
471
+ prompt = `## Upstream agent output:\n${upstreamContext}\n\n---\n\n${prompt}`;
472
+ }
473
+
474
+ // Note: [SEND:] bus markers disabled. Agent communication now uses forge skills (/forge-send, /forge-inbox).
475
+ // Phase 2 will add ticket system + causedBy protocol for structured agent-to-agent feedback.
476
+
477
+ return prompt;
478
+ }
479
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Workspace Agent System — public API.
3
+ */
4
+
5
+ // Types
6
+ export type {
7
+ WorkspaceAgentConfig,
8
+ AgentBackendType,
9
+ AgentStep,
10
+ AgentStatus,
11
+ AgentState,
12
+ Artifact,
13
+ BusMessage,
14
+ WorkspaceState,
15
+ AgentBackend,
16
+ StepExecutionParams,
17
+ StepExecutionResult,
18
+ WorkerEvent,
19
+ } from './types';
20
+
21
+ // Core
22
+ export { AgentWorker, type AgentWorkerOptions } from './agent-worker';
23
+ export { AgentBus } from './agent-bus';
24
+ export { WorkspaceOrchestrator, type OrchestratorEvent } from './orchestrator';
25
+
26
+ // Backends
27
+ export { ApiBackend } from './backends/api-backend';
28
+ export { CliBackend } from './backends/cli-backend';
29
+
30
+ // Presets
31
+ export { AGENT_PRESETS, createDeliveryPipeline, createFromPreset } from './presets';
32
+
33
+ // Manager (singleton orchestrator cache + SSE)
34
+ export {
35
+ getOrchestrator,
36
+ createOrchestratorFromState,
37
+ getOrchestratorByProject,
38
+ subscribeSSE,
39
+ shutdownOrchestrator,
40
+ shutdownAll,
41
+ } from './manager';
42
+
43
+ // Persistence
44
+ export {
45
+ saveWorkspace,
46
+ loadWorkspace,
47
+ listWorkspaces,
48
+ findWorkspaceByProject,
49
+ deleteWorkspace,
50
+ readAgentLog,
51
+ readAgentLogTail,
52
+ appendAgentLog,
53
+ startAutoSave,
54
+ stopAutoSave,
55
+ type WorkspaceSummary,
56
+ } from './persistence';
57
+
58
+ // Smith Memory
59
+ export {
60
+ loadMemory,
61
+ saveMemory,
62
+ createMemory,
63
+ addObservation,
64
+ addSessionSummary,
65
+ formatMemoryForPrompt,
66
+ formatMemoryForDisplay,
67
+ getMemoryStats,
68
+ parseStepToObservations,
69
+ buildSessionSummary,
70
+ type SmithMemory,
71
+ type Observation,
72
+ type ObservationType,
73
+ type SessionSummary,
74
+ type MemoryDisplayEntry,
75
+ } from './smith-memory';
76
+
77
+ // Skill installer
78
+ export {
79
+ installForgeSkills,
80
+ hasForgeSkills,
81
+ removeForgeSkills,
82
+ } from './skill-installer';
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Workspace Manager — singleton that manages live orchestrator instances.
3
+ *
4
+ * API routes use this to get/create orchestrators.
5
+ * Orchestrators are cached per workspace ID.
6
+ */
7
+
8
+ import { WorkspaceOrchestrator, type OrchestratorEvent } from './orchestrator';
9
+ import { loadWorkspace, saveWorkspace } from './persistence';
10
+ import type { WorkspaceState, WorkspaceAgentConfig } from './types';
11
+
12
+ // Persist across HMR in dev mode
13
+ const g = globalThis as any;
14
+ if (!g.__forgeOrchestrators) g.__forgeOrchestrators = new Map<string, WorkspaceOrchestrator>();
15
+ if (!g.__forgeSseListeners) g.__forgeSseListeners = new Map<string, Set<(event: OrchestratorEvent) => void>>();
16
+
17
+ const orchestrators: Map<string, WorkspaceOrchestrator> = g.__forgeOrchestrators;
18
+ const sseListeners: Map<string, Set<(event: OrchestratorEvent) => void>> = g.__forgeSseListeners;
19
+
20
+ /** Force reload an orchestrator from disk (clears cache) */
21
+ export function reloadOrchestrator(workspaceId: string): WorkspaceOrchestrator | null {
22
+ const existing = orchestrators.get(workspaceId);
23
+ if (existing) {
24
+ existing.shutdown();
25
+ orchestrators.delete(workspaceId);
26
+ }
27
+ return getOrchestrator(workspaceId);
28
+ }
29
+
30
+ /**
31
+ * Get or create an orchestrator for a workspace.
32
+ * Loads from disk if not in memory.
33
+ */
34
+ export function getOrchestrator(workspaceId: string): WorkspaceOrchestrator | null {
35
+ const existing = orchestrators.get(workspaceId);
36
+ if (existing) return existing;
37
+
38
+ // Try to load from disk
39
+ const state = loadWorkspace(workspaceId);
40
+ if (!state) return null;
41
+
42
+ return createOrchestratorFromState(state);
43
+ }
44
+
45
+ /**
46
+ * Create a new orchestrator for a workspace state.
47
+ */
48
+ export function createOrchestratorFromState(state: WorkspaceState): WorkspaceOrchestrator {
49
+ // If already cached (e.g. called twice), return existing
50
+ const cached = orchestrators.get(state.id);
51
+ if (cached) return cached;
52
+
53
+ const orch = new WorkspaceOrchestrator(state.id, state.projectPath, state.projectName);
54
+
55
+ // Load existing agents and states
56
+ if (state.agents.length > 0) {
57
+ orch.loadSnapshot({
58
+ agents: state.agents,
59
+ agentStates: state.agentStates,
60
+ busLog: state.busLog,
61
+ busOutbox: state.busOutbox,
62
+ });
63
+ }
64
+
65
+ // Forward events to SSE listeners
66
+ orch.on('event', (event: OrchestratorEvent) => {
67
+ const listeners = sseListeners.get(state.id);
68
+ if (listeners) {
69
+ for (const fn of listeners) {
70
+ try { fn(event); } catch {}
71
+ }
72
+ }
73
+ });
74
+
75
+ orchestrators.set(state.id, orch);
76
+ return orch;
77
+ }
78
+
79
+ /**
80
+ * Get orchestrator by project path (finds workspace first).
81
+ */
82
+ export function getOrchestratorByProject(projectPath: string): WorkspaceOrchestrator | null {
83
+ // Check cached
84
+ for (const [, orch] of orchestrators) {
85
+ if (orch.projectPath === projectPath) return orch;
86
+ }
87
+
88
+ // Try loading from disk
89
+ const { findWorkspaceByProject } = require('./persistence');
90
+ const state = findWorkspaceByProject(projectPath);
91
+ if (!state) return null;
92
+
93
+ return getOrchestrator(state.id);
94
+ }
95
+
96
+ /**
97
+ * Subscribe to SSE events for a workspace.
98
+ */
99
+ export function subscribeSSE(workspaceId: string, listener: (event: OrchestratorEvent) => void): () => void {
100
+ let listeners = sseListeners.get(workspaceId);
101
+ if (!listeners) {
102
+ listeners = new Set();
103
+ sseListeners.set(workspaceId, listeners);
104
+ }
105
+ listeners.add(listener);
106
+
107
+ // Return unsubscribe function
108
+ return () => {
109
+ listeners!.delete(listener);
110
+ if (listeners!.size === 0) {
111
+ sseListeners.delete(workspaceId);
112
+ }
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Shutdown a specific orchestrator.
118
+ */
119
+ export function shutdownOrchestrator(workspaceId: string): void {
120
+ const orch = orchestrators.get(workspaceId);
121
+ if (orch) {
122
+ orch.shutdown();
123
+ orchestrators.delete(workspaceId);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Shutdown all orchestrators (on server stop).
129
+ */
130
+ export function shutdownAll(): void {
131
+ for (const [id, orch] of orchestrators) {
132
+ orch.shutdown();
133
+ }
134
+ orchestrators.clear();
135
+ sseListeners.clear();
136
+ }