@aion0/forge 0.5.22 → 0.5.24
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/RELEASE_NOTES.md +16 -29
- package/app/api/smith-templates/route.ts +81 -0
- package/components/SettingsModal.tsx +6 -1
- package/components/WorkspaceView.tsx +841 -83
- package/lib/claude-sessions.ts +2 -1
- package/lib/forge-mcp-server.ts +247 -33
- package/lib/help-docs/11-workspace.md +722 -166
- package/lib/telegram-bot.ts +1 -1
- package/lib/workspace/orchestrator.ts +292 -76
- package/lib/workspace/presets.ts +535 -58
- package/lib/workspace/requests.ts +287 -0
- package/lib/workspace/session-monitor.ts +4 -3
- package/lib/workspace/types.ts +1 -0
- package/lib/workspace/watch-manager.ts +1 -1
- package/lib/workspace-standalone.ts +1 -1
- package/package.json +1 -1
- package/scripts/bench/README.md +66 -0
- package/scripts/bench/results/.gitignore +2 -0
- package/scripts/bench/run.ts +635 -0
- package/scripts/bench/tasks/01-text-utils/task.md +26 -0
- package/scripts/bench/tasks/01-text-utils/validator.sh +46 -0
- package/scripts/bench/tasks/02-pagination/setup.sh +19 -0
- package/scripts/bench/tasks/02-pagination/task.md +48 -0
- package/scripts/bench/tasks/02-pagination/validator.sh +69 -0
- package/scripts/bench/tasks/03-bug-fix/setup.sh +82 -0
- package/scripts/bench/tasks/03-bug-fix/task.md +30 -0
- package/scripts/bench/tasks/03-bug-fix/validator.sh +29 -0
- package/templates/smith-lead.json +45 -0
package/lib/telegram-bot.ts
CHANGED
|
@@ -1129,7 +1129,7 @@ async function handleDocs(chatId: number, input: string) {
|
|
|
1129
1129
|
}
|
|
1130
1130
|
|
|
1131
1131
|
// /docs — show summary of latest Claude session for docs
|
|
1132
|
-
const hash = docRoot.replace(
|
|
1132
|
+
const hash = docRoot.replace(/[^a-zA-Z0-9]/g, '-');
|
|
1133
1133
|
const claudeDir = join(getHome(), '.claude', 'projects', hash);
|
|
1134
1134
|
|
|
1135
1135
|
if (!existsSync(claudeDir)) {
|
|
@@ -41,6 +41,30 @@ import {
|
|
|
41
41
|
} from './smith-memory';
|
|
42
42
|
import { getFixedSession } from '../project-sessions';
|
|
43
43
|
|
|
44
|
+
// ─── Workspace Topology Cache ────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface TopoAgent {
|
|
47
|
+
id: string;
|
|
48
|
+
label: string;
|
|
49
|
+
icon: string;
|
|
50
|
+
role: string; // full role text
|
|
51
|
+
roleSummary: string; // first line, ≤150 chars
|
|
52
|
+
primary: boolean;
|
|
53
|
+
dependsOn: string[]; // agent labels (not IDs)
|
|
54
|
+
dependsOnIds: string[];
|
|
55
|
+
workDir: string;
|
|
56
|
+
outputs: string[];
|
|
57
|
+
steps: string[]; // step labels
|
|
58
|
+
smithStatus: string;
|
|
59
|
+
taskStatus: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WorkspaceTopo {
|
|
63
|
+
agents: TopoAgent[];
|
|
64
|
+
flow: string; // "Lead → Engineer → QA → Reviewer"
|
|
65
|
+
updatedAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
// ─── Orchestrator Events ─────────────────────────────────
|
|
45
69
|
|
|
46
70
|
export type OrchestratorEvent =
|
|
@@ -70,6 +94,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
70
94
|
private settingsValidCache = new Map<string, number>(); // filePath → mtime (validated ok)
|
|
71
95
|
private agentRunningMsg = new Map<string, string>(); // agentId → messageId currently being processed
|
|
72
96
|
private reconcileTick = 0; // counts health check ticks for 60s reconcile
|
|
97
|
+
private _topoCache: WorkspaceTopo | null = null; // cached workspace topology
|
|
98
|
+
private roleInjectState = new Map<string, { lastInjectAt: number; msgsSinceInject: number }>(); // per-agent role reminder tracking
|
|
73
99
|
|
|
74
100
|
/** Emit a log event (auto-persisted via constructor listener) */
|
|
75
101
|
emitLog(agentId: string, entry: any): void {
|
|
@@ -156,16 +182,29 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
156
182
|
return `Work directory conflict: "${config.label}" (${newDir}/) overlaps with "${entry.config.label}" (${existingDir}/). Nested directories not allowed.`;
|
|
157
183
|
}
|
|
158
184
|
|
|
159
|
-
//
|
|
185
|
+
// Output path overlap is allowed — multiple agents can write to the same
|
|
186
|
+
// output directory (e.g., Engineer and UI Designer both output to src/).
|
|
187
|
+
// Work directory uniqueness already prevents file-level conflicts.
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Check for non-blocking output overlaps — returns a warning message if any */
|
|
193
|
+
private getOutputWarnings(config: WorkspaceAgentConfig, excludeId?: string): string | null {
|
|
194
|
+
if (config.type === 'input' || !config.outputs?.length) return null;
|
|
195
|
+
const normalize = (p: string) => p.replace(/^\.?\//, '').replace(/\/$/, '') || '.';
|
|
196
|
+
const overlaps: string[] = [];
|
|
197
|
+
for (const [id, entry] of this.agents) {
|
|
198
|
+
if (id === excludeId || entry.config.type === 'input') continue;
|
|
160
199
|
for (const out of config.outputs) {
|
|
161
|
-
for (const existing of entry.config.outputs) {
|
|
200
|
+
for (const existing of entry.config.outputs || []) {
|
|
162
201
|
if (normalize(out) === normalize(existing)) {
|
|
163
|
-
|
|
202
|
+
overlaps.push(`${entry.config.label} also outputs to "${out}"`);
|
|
164
203
|
}
|
|
165
204
|
}
|
|
166
205
|
}
|
|
167
206
|
}
|
|
168
|
-
return null;
|
|
207
|
+
return overlaps.length ? `Output overlap (non-blocking): ${overlaps.join('; ')}` : null;
|
|
169
208
|
}
|
|
170
209
|
|
|
171
210
|
/** Detect if adding dependsOn edges would create a cycle in the DAG */
|
|
@@ -229,6 +268,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
229
268
|
addAgent(config: WorkspaceAgentConfig): void {
|
|
230
269
|
const conflict = this.validateOutputs(config);
|
|
231
270
|
if (conflict) throw new Error(conflict);
|
|
271
|
+
const warning = this.getOutputWarnings(config);
|
|
272
|
+
if (warning) console.warn(`[workspace] ${warning}`);
|
|
232
273
|
|
|
233
274
|
// Check DAG cycle before adding
|
|
234
275
|
const cycleErr = this.detectCycle(config.id, config.dependsOn);
|
|
@@ -308,8 +349,22 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
308
349
|
updateAgentConfig(id: string, config: WorkspaceAgentConfig): void {
|
|
309
350
|
const entry = this.agents.get(id);
|
|
310
351
|
if (!entry) return;
|
|
352
|
+
// Validate agentId exists — fallback to default if deleted from Settings
|
|
353
|
+
if (config.agentId) {
|
|
354
|
+
try {
|
|
355
|
+
const { listAgents, getDefaultAgentId } = require('../agents/index');
|
|
356
|
+
const validAgents = new Set((listAgents() as any[]).map((a: any) => a.id));
|
|
357
|
+
if (!validAgents.has(config.agentId)) {
|
|
358
|
+
const fallback = getDefaultAgentId() || 'claude';
|
|
359
|
+
console.log(`[workspace] ${config.label}: agent "${config.agentId}" not found, falling back to "${fallback}"`);
|
|
360
|
+
config.agentId = fallback;
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
311
364
|
const conflict = this.validateOutputs(config, id);
|
|
312
365
|
if (conflict) throw new Error(conflict);
|
|
366
|
+
const warning = this.getOutputWarnings(config, id);
|
|
367
|
+
if (warning) console.warn(`[workspace] ${warning}`);
|
|
313
368
|
const cycleErr = this.detectCycle(id, config.dependsOn);
|
|
314
369
|
if (cycleErr) throw new Error(cycleErr);
|
|
315
370
|
this.validatePrimaryRules(config, id);
|
|
@@ -744,8 +799,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
744
799
|
}
|
|
745
800
|
}
|
|
746
801
|
|
|
747
|
-
//
|
|
748
|
-
|
|
802
|
+
// Role is now injected via buildUpstreamContext (headless) and persistent session preamble (terminal).
|
|
803
|
+
// No longer writes to CLAUDE.md — project files stay clean when daemon stops.
|
|
749
804
|
|
|
750
805
|
let upstreamContext = this.buildUpstreamContext(config);
|
|
751
806
|
if (userInput) {
|
|
@@ -879,6 +934,23 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
879
934
|
installForgeSkills(this.projectPath, this.workspaceId, '', Number(process.env.PORT) || 8403);
|
|
880
935
|
} catch {}
|
|
881
936
|
|
|
937
|
+
// Validate agent IDs — fallback to default if configured agent was deleted from Settings
|
|
938
|
+
let defaultAgentId = 'claude';
|
|
939
|
+
try {
|
|
940
|
+
const { listAgents, getDefaultAgentId } = await import('../agents/index') as any;
|
|
941
|
+
const validAgents = new Set((listAgents() as any[]).map(a => a.id));
|
|
942
|
+
defaultAgentId = getDefaultAgentId() || 'claude';
|
|
943
|
+
for (const [id, entry] of this.agents) {
|
|
944
|
+
if (entry.config.type === 'input') continue;
|
|
945
|
+
if (entry.config.agentId && !validAgents.has(entry.config.agentId)) {
|
|
946
|
+
console.log(`[daemon] ${entry.config.label}: agent "${entry.config.agentId}" not found, falling back to "${defaultAgentId}"`);
|
|
947
|
+
entry.config.agentId = defaultAgentId;
|
|
948
|
+
this.emit('event', { type: 'log', agentId: id, entry: { type: 'system', subtype: 'warning', content: `Agent "${entry.config.agentId}" not found in Settings — using default "${defaultAgentId}"`, timestamp: new Date().toISOString() } } as any);
|
|
949
|
+
this.saveNow();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
} catch {}
|
|
953
|
+
|
|
882
954
|
// Start each smith one by one, verify each starts correctly
|
|
883
955
|
let started = 0;
|
|
884
956
|
let failed = 0;
|
|
@@ -903,8 +975,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
903
975
|
throw new Error('Worker not created');
|
|
904
976
|
}
|
|
905
977
|
|
|
906
|
-
// 3. Set smith status
|
|
907
|
-
entry.state.smithStatus = 'active';
|
|
978
|
+
// 3. Set smith status — persistent session agents stay 'starting' until ensurePersistentSession completes
|
|
979
|
+
entry.state.smithStatus = entry.config.persistentSession ? 'starting' : 'active';
|
|
908
980
|
entry.state.error = undefined;
|
|
909
981
|
|
|
910
982
|
// 4. Start message loop (delayed for persistent session agents — session must exist first)
|
|
@@ -916,10 +988,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
916
988
|
this.updateAgentLiveness(id);
|
|
917
989
|
|
|
918
990
|
// 6. Notify frontend
|
|
919
|
-
this.emit('event', { type: 'smith_status', agentId: id, smithStatus:
|
|
991
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: entry.state.smithStatus } as any);
|
|
920
992
|
|
|
921
993
|
started++;
|
|
922
|
-
console.log(`[daemon] ✓ ${entry.config.label}:
|
|
994
|
+
console.log(`[daemon] ✓ ${entry.config.label}: ${entry.state.smithStatus} (task=${entry.state.taskStatus})`);
|
|
923
995
|
} catch (err: any) {
|
|
924
996
|
entry.state.smithStatus = 'down';
|
|
925
997
|
entry.state.error = `Failed to start: ${err.message}`;
|
|
@@ -929,18 +1001,36 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
929
1001
|
}
|
|
930
1002
|
}
|
|
931
1003
|
|
|
1004
|
+
// Migration: clean any legacy Forge blocks from CLAUDE.md files (role/topo no longer written to disk)
|
|
1005
|
+
this.cleanLegacyClaudeMdBlocks();
|
|
1006
|
+
|
|
932
1007
|
// Create persistent terminal sessions, then start their message loops
|
|
933
1008
|
for (const [id, entry] of this.agents) {
|
|
934
1009
|
if (entry.config.type === 'input' || !entry.config.persistentSession) continue;
|
|
935
1010
|
await this.ensurePersistentSession(id, entry.config);
|
|
936
|
-
//
|
|
1011
|
+
// Set active now that session + boundSessionId are ready
|
|
1012
|
+
if (entry.state.smithStatus === 'starting') {
|
|
1013
|
+
entry.state.smithStatus = 'active';
|
|
1014
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
|
|
1015
|
+
}
|
|
937
1016
|
if (entry.state.smithStatus === 'active') {
|
|
1017
|
+
// Inject role preamble so Claude knows its role (replaces CLAUDE.md writing)
|
|
1018
|
+
if (entry.config.role?.trim()) {
|
|
1019
|
+
const preamble = this.buildRolePreamble(entry.config);
|
|
1020
|
+
if (this.injectIntoSession(id, preamble)) {
|
|
1021
|
+
this.markRoleInjected(id);
|
|
1022
|
+
console.log(`[daemon] ${entry.config.label}: injected role preamble`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
938
1025
|
this.startMessageLoop(id);
|
|
939
1026
|
} else {
|
|
940
1027
|
console.log(`[daemon] ${entry.config.label}: skipped message loop (smith=${entry.state.smithStatus})`);
|
|
941
1028
|
}
|
|
942
1029
|
}
|
|
943
1030
|
|
|
1031
|
+
// Build workspace topology cache (all smiths can query via MCP get_agents)
|
|
1032
|
+
this.rebuildTopo();
|
|
1033
|
+
|
|
944
1034
|
// Start watch loops for agents with watch config
|
|
945
1035
|
this.watchManager.start();
|
|
946
1036
|
|
|
@@ -1085,6 +1175,20 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1085
1175
|
entry.worker = null;
|
|
1086
1176
|
}
|
|
1087
1177
|
|
|
1178
|
+
// 2b. For persistent sessions: send /clear to reset Claude's context if user is attached.
|
|
1179
|
+
// This is a Claude Code slash command — no LLM call, just a local context reset.
|
|
1180
|
+
if (entry.state.tmuxSession && entry.config.role?.trim()) {
|
|
1181
|
+
let isAttached = false;
|
|
1182
|
+
try {
|
|
1183
|
+
const info = execSync(`tmux display-message -t "${entry.state.tmuxSession}" -p "#{session_attached}" 2>/dev/null`, { timeout: 3000, encoding: 'utf-8' }).trim();
|
|
1184
|
+
isAttached = info !== '0';
|
|
1185
|
+
} catch {}
|
|
1186
|
+
if (isAttached) {
|
|
1187
|
+
try { this.injectIntoSession(id, '/clear'); } catch {}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this.roleInjectState.delete(id);
|
|
1191
|
+
|
|
1088
1192
|
// 3. Kill tmux session (skip if user is attached to it)
|
|
1089
1193
|
if (entry.state.tmuxSession) {
|
|
1090
1194
|
let isAttached = false;
|
|
@@ -1856,69 +1960,82 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1856
1960
|
|
|
1857
1961
|
// ─── Private ───────────────────────────────────────────
|
|
1858
1962
|
|
|
1859
|
-
/**
|
|
1860
|
-
private
|
|
1861
|
-
|
|
1862
|
-
const
|
|
1863
|
-
const
|
|
1963
|
+
/** Rebuild workspace topology cache — called on agent changes and daemon start */
|
|
1964
|
+
private rebuildTopo(): void {
|
|
1965
|
+
// Topological sort
|
|
1966
|
+
const sorted: WorkspaceAgentConfig[] = [];
|
|
1967
|
+
const visited = new Set<string>();
|
|
1968
|
+
const entries = Array.from(this.agents.values()).filter(e => e.config.type !== 'input');
|
|
1969
|
+
const configMap = new Map(entries.map(e => [e.config.id, e.config]));
|
|
1970
|
+
|
|
1971
|
+
const visit = (id: string) => {
|
|
1972
|
+
if (visited.has(id)) return;
|
|
1973
|
+
visited.add(id);
|
|
1974
|
+
const c = configMap.get(id);
|
|
1975
|
+
if (!c) return;
|
|
1976
|
+
for (const dep of c.dependsOn) visit(dep);
|
|
1977
|
+
sorted.push(c);
|
|
1978
|
+
};
|
|
1979
|
+
for (const e of entries) visit(e.config.id);
|
|
1980
|
+
|
|
1981
|
+
const agents: TopoAgent[] = sorted.map(c => {
|
|
1982
|
+
const state = this.agents.get(c.id)?.state;
|
|
1983
|
+
return {
|
|
1984
|
+
id: c.id,
|
|
1985
|
+
label: c.label,
|
|
1986
|
+
icon: c.icon,
|
|
1987
|
+
role: c.role || '',
|
|
1988
|
+
roleSummary: (c.role || '').split('\n')[0].slice(0, 150),
|
|
1989
|
+
primary: !!c.primary,
|
|
1990
|
+
dependsOn: c.dependsOn.map(d => this.agents.get(d)?.config.label || d),
|
|
1991
|
+
dependsOnIds: [...c.dependsOn],
|
|
1992
|
+
workDir: c.workDir || './',
|
|
1993
|
+
outputs: c.outputs || [],
|
|
1994
|
+
steps: (c.steps || []).map(s => s.label),
|
|
1995
|
+
smithStatus: state?.smithStatus || 'down',
|
|
1996
|
+
taskStatus: state?.taskStatus || 'idle',
|
|
1997
|
+
};
|
|
1998
|
+
});
|
|
1864
1999
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
pluginDocs = '\n\n## Available Plugins (via MCP run_plugin)\n';
|
|
1878
|
-
for (const p of relevant) {
|
|
1879
|
-
const def = p.definition;
|
|
1880
|
-
const name = p.instanceName || def.name;
|
|
1881
|
-
pluginDocs += `\n### ${def.icon} ${name} (id: "${p.id}")\n`;
|
|
1882
|
-
if (def.description) pluginDocs += `${def.description}\n`;
|
|
1883
|
-
pluginDocs += '\nActions:\n';
|
|
1884
|
-
for (const [actionName, action] of Object.entries(def.actions) as any[]) {
|
|
1885
|
-
pluginDocs += `- **${actionName}** (${action.run})`;
|
|
1886
|
-
if (def.defaultAction === actionName) pluginDocs += ' [default]';
|
|
1887
|
-
pluginDocs += '\n';
|
|
1888
|
-
}
|
|
1889
|
-
if (Object.keys(def.params).length > 0) {
|
|
1890
|
-
pluginDocs += 'Params: ' + Object.entries(def.params).map(([k, v]: any) =>
|
|
1891
|
-
`${k}${v.required ? '*' : ''} (${v.type})`
|
|
1892
|
-
).join(', ') + '\n';
|
|
1893
|
-
}
|
|
1894
|
-
pluginDocs += `\nUsage: run_plugin({ plugin: "${p.id}", action: "<action>", params: { ... } })\n`;
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
} catch {}
|
|
1898
|
-
}
|
|
2000
|
+
this._topoCache = {
|
|
2001
|
+
agents,
|
|
2002
|
+
flow: sorted.map(c => c.label).join(' → '),
|
|
2003
|
+
updatedAt: Date.now(),
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/** Get cached workspace topology (always fresh — rebuilt on every agent change) */
|
|
2008
|
+
getWorkspaceTopo(): WorkspaceTopo {
|
|
2009
|
+
if (!this._topoCache) this.rebuildTopo();
|
|
2010
|
+
return this._topoCache!;
|
|
2011
|
+
}
|
|
1899
2012
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2013
|
+
/** Remove stale Forge blocks from all agents' CLAUDE.md files (migration cleanup).
|
|
2014
|
+
* Role is no longer written to CLAUDE.md — this just removes legacy artifacts. */
|
|
2015
|
+
private cleanLegacyClaudeMdBlocks(): void {
|
|
2016
|
+
const roleRegex = /<!-- forge:agent-role -->[\s\S]*?<!-- \/forge:agent-role -->\n?/;
|
|
2017
|
+
const topoRegex = /<!-- forge:workspace-topo -->[\s\S]*?<!-- \/forge:workspace-topo -->\n?/;
|
|
1903
2018
|
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2019
|
+
for (const [, entry] of this.agents) {
|
|
2020
|
+
if (entry.config.type === 'input') continue;
|
|
2021
|
+
const workDir = join(this.projectPath, entry.config.workDir || '');
|
|
2022
|
+
const claudeMdPath = join(workDir, 'CLAUDE.md');
|
|
2023
|
+
if (!existsSync(claudeMdPath)) continue;
|
|
2024
|
+
|
|
2025
|
+
try {
|
|
2026
|
+
let content = readFileSync(claudeMdPath, 'utf-8');
|
|
2027
|
+
const before = content;
|
|
2028
|
+
if (roleRegex.test(content)) content = content.replace(roleRegex, '');
|
|
2029
|
+
if (topoRegex.test(content)) content = content.replace(topoRegex, '');
|
|
2030
|
+
if (content !== before) {
|
|
2031
|
+
content = content.trimEnd() + (content.trim() ? '\n' : '');
|
|
2032
|
+
if (content.trim()) writeFileSync(claudeMdPath, content);
|
|
2033
|
+
else unlinkSync(claudeMdPath); // was only forge content, delete empty file
|
|
2034
|
+
console.log(`[workspace] Cleaned legacy Forge blocks from ${claudeMdPath}`);
|
|
1914
2035
|
}
|
|
2036
|
+
} catch (err: any) {
|
|
2037
|
+
console.warn(`[workspace] Failed to clean CLAUDE.md at ${claudeMdPath}: ${err.message}`);
|
|
1915
2038
|
}
|
|
1916
|
-
// Append
|
|
1917
|
-
mkdirSync(workDir, { recursive: true });
|
|
1918
|
-
const separator = content && !content.endsWith('\n') ? '\n\n' : content ? '\n' : '';
|
|
1919
|
-
writeFileSync(claudeMdPath, content + separator + roleBlock + '\n');
|
|
1920
|
-
} catch (err) {
|
|
1921
|
-
console.warn(`[workspace] Failed to sync role to CLAUDE.md for ${config.label}:`, err);
|
|
1922
2039
|
}
|
|
1923
2040
|
}
|
|
1924
2041
|
|
|
@@ -1945,11 +2062,89 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1945
2062
|
}
|
|
1946
2063
|
}
|
|
1947
2064
|
|
|
2065
|
+
/** Build a compact topo summary injected into every agent task */
|
|
2066
|
+
private buildTopoSummary(selfId: string): string {
|
|
2067
|
+
const topo = this.getWorkspaceTopo();
|
|
2068
|
+
if (topo.agents.length === 0) return '';
|
|
2069
|
+
|
|
2070
|
+
const lines: string[] = ['## Workspace Team'];
|
|
2071
|
+
lines.push(`Flow: ${topo.flow}`);
|
|
2072
|
+
|
|
2073
|
+
// Missing roles hint
|
|
2074
|
+
const labels = new Set(topo.agents.map(a => a.label.toLowerCase()));
|
|
2075
|
+
const standard = ['architect', 'engineer', 'qa', 'reviewer', 'pm', 'lead'];
|
|
2076
|
+
const missing = standard.filter(r => !labels.has(r));
|
|
2077
|
+
if (missing.length > 0) lines.push(`Missing: ${missing.join(', ')}`);
|
|
2078
|
+
|
|
2079
|
+
for (const a of topo.agents) {
|
|
2080
|
+
const me = a.id === selfId ? ' ← you' : '';
|
|
2081
|
+
const status = `${a.smithStatus}/${a.taskStatus}`;
|
|
2082
|
+
lines.push(`- ${a.icon} ${a.label}${me} [${status}]: ${a.roleSummary}`);
|
|
2083
|
+
}
|
|
2084
|
+
return lines.join('\n');
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** Build full role preamble — injected once when persistent session starts */
|
|
2088
|
+
private buildRolePreamble(config: WorkspaceAgentConfig): string {
|
|
2089
|
+
const roleText = (config.role || '').trim() || '(no role defined)';
|
|
2090
|
+
return `=== SMITH CONTEXT (managed by Forge) ===
|
|
2091
|
+
You are ${config.label}. Work dir: ${config.workDir || './'}.
|
|
2092
|
+
|
|
2093
|
+
Your role and responsibilities:
|
|
2094
|
+
${roleText}
|
|
2095
|
+
|
|
2096
|
+
---
|
|
2097
|
+
Silently ingest this context. Do NOT respond — await an actual task.`;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/** Build short role reminder — injected periodically to combat auto-compaction */
|
|
2101
|
+
private buildRoleReminder(config: WorkspaceAgentConfig): string {
|
|
2102
|
+
const roleText = (config.role || '').trim();
|
|
2103
|
+
// First 300 chars of role, plus label
|
|
2104
|
+
const summary = roleText.length > 300 ? roleText.slice(0, 300) + '...' : roleText;
|
|
2105
|
+
return `[Role reminder — you are ${config.label}]\n${summary}\n---`;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** Check if a role reminder should be re-injected before next bus message */
|
|
2109
|
+
private needsRoleReminder(agentId: string): boolean {
|
|
2110
|
+
const st = this.roleInjectState.get(agentId);
|
|
2111
|
+
if (!st) return false; // no preamble sent yet — session is starting
|
|
2112
|
+
const now = Date.now();
|
|
2113
|
+
const REMIND_EVERY_MSGS = 10;
|
|
2114
|
+
const REMIND_EVERY_MS = 30 * 60 * 1000; // 30 min
|
|
2115
|
+
if (st.msgsSinceInject >= REMIND_EVERY_MSGS) return true;
|
|
2116
|
+
if (now - st.lastInjectAt >= REMIND_EVERY_MS) return true;
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/** Mark that role was just injected for this agent */
|
|
2121
|
+
private markRoleInjected(agentId: string): void {
|
|
2122
|
+
this.roleInjectState.set(agentId, { lastInjectAt: Date.now(), msgsSinceInject: 0 });
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/** Increment message counter since last role injection */
|
|
2126
|
+
private incrementMsgCount(agentId: string): void {
|
|
2127
|
+
const st = this.roleInjectState.get(agentId);
|
|
2128
|
+
if (st) st.msgsSinceInject++;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
1948
2131
|
/** Build context string from upstream agents' outputs */
|
|
1949
2132
|
private buildUpstreamContext(config: WorkspaceAgentConfig): string | undefined {
|
|
1950
|
-
|
|
2133
|
+
// Always prepend: role + workspace team summary
|
|
2134
|
+
const roleBlock = config.role?.trim() ? `## Your Role (${config.label})\n${config.role.trim()}` : '';
|
|
2135
|
+
const topoSummary = this.buildTopoSummary(config.id);
|
|
2136
|
+
|
|
2137
|
+
const headerSections: string[] = [];
|
|
2138
|
+
if (roleBlock) headerSections.push(roleBlock);
|
|
2139
|
+
if (topoSummary) headerSections.push(topoSummary);
|
|
2140
|
+
const header = headerSections.join('\n\n');
|
|
2141
|
+
|
|
2142
|
+
if (config.dependsOn.length === 0) {
|
|
2143
|
+
return header || undefined;
|
|
2144
|
+
}
|
|
1951
2145
|
|
|
1952
2146
|
const sections: string[] = [];
|
|
2147
|
+
if (header) sections.push(header);
|
|
1953
2148
|
|
|
1954
2149
|
for (const depId of config.dependsOn) {
|
|
1955
2150
|
const dep = this.agents.get(depId);
|
|
@@ -2117,7 +2312,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2117
2312
|
private getCliSessionDir(workDir?: string): string {
|
|
2118
2313
|
const projectPath = workDir && workDir !== './' && workDir !== '.'
|
|
2119
2314
|
? join(this.projectPath, workDir) : this.projectPath;
|
|
2120
|
-
|
|
2315
|
+
// Claude Code encodes paths by replacing all non-alphanumeric chars with '-'
|
|
2316
|
+
const encoded = resolve(projectPath).replace(/[^a-zA-Z0-9]/g, '-');
|
|
2121
2317
|
return join(homedir(), '.claude', 'projects', encoded);
|
|
2122
2318
|
}
|
|
2123
2319
|
|
|
@@ -2582,8 +2778,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2582
2778
|
const tmpFile = `/tmp/forge-inject-${Date.now()}.txt`;
|
|
2583
2779
|
writeFileSync(tmpFile, text);
|
|
2584
2780
|
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
2585
|
-
execSync(`tmux paste-buffer -t "${tmuxSession}"`, { timeout: 5000 });
|
|
2586
|
-
execSync(`tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
2781
|
+
execSync(`tmux paste-buffer -t "${tmuxSession}" && sleep 0.2 && tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
|
|
2587
2782
|
try { unlinkSync(tmpFile); } catch {}
|
|
2588
2783
|
return true;
|
|
2589
2784
|
} catch (err: any) {
|
|
@@ -2842,7 +3037,16 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2842
3037
|
|
|
2843
3038
|
// Terminal mode → inject; headless → worker (claude -p)
|
|
2844
3039
|
if (isTerminalMode) {
|
|
2845
|
-
|
|
3040
|
+
// Check if role reminder needed (combats auto-compaction over long sessions)
|
|
3041
|
+
let messageText = nextMsg.payload.content || nextMsg.payload.action;
|
|
3042
|
+
if (this.needsRoleReminder(agentId) && entry.config.role?.trim()) {
|
|
3043
|
+
messageText = this.buildRoleReminder(entry.config) + '\n\n' + messageText;
|
|
3044
|
+
this.markRoleInjected(agentId);
|
|
3045
|
+
console.log(`[inbox] ${entry.config.label}: prepended role reminder`);
|
|
3046
|
+
} else {
|
|
3047
|
+
this.incrementMsgCount(agentId);
|
|
3048
|
+
}
|
|
3049
|
+
const injected = this.injectIntoSession(agentId, messageText);
|
|
2846
3050
|
if (injected) {
|
|
2847
3051
|
entry.state.taskStatus = 'running';
|
|
2848
3052
|
this.emit('event', { type: 'task_status', agentId, taskStatus: 'running' } as any);
|
|
@@ -3025,8 +3229,18 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
3025
3229
|
r.type === 'response' && r.payload.replyTo === m.id
|
|
3026
3230
|
)
|
|
3027
3231
|
);
|
|
3028
|
-
|
|
3029
|
-
|
|
3232
|
+
// Also check request documents are all done
|
|
3233
|
+
let requestsComplete = true;
|
|
3234
|
+
try {
|
|
3235
|
+
const { listRequests } = require('./requests');
|
|
3236
|
+
const allReqs = listRequests(this.projectPath);
|
|
3237
|
+
if (allReqs.length > 0) {
|
|
3238
|
+
requestsComplete = allReqs.every((r: any) => r.status === 'done' || r.status === 'rejected');
|
|
3239
|
+
}
|
|
3240
|
+
} catch {}
|
|
3241
|
+
|
|
3242
|
+
if (!hasPendingRequests && requestsComplete) {
|
|
3243
|
+
console.log('[workspace] All agents complete, no pending requests, all request docs done. Workspace done.');
|
|
3030
3244
|
this.emit('event', { type: 'workspace_complete' } satisfies OrchestratorEvent);
|
|
3031
3245
|
}
|
|
3032
3246
|
}
|
|
@@ -3129,6 +3343,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
3129
3343
|
|
|
3130
3344
|
/** Emit agents_changed so SSE pushes the updated list to frontend */
|
|
3131
3345
|
private emitAgentsChanged(): void {
|
|
3346
|
+
// Refresh topology cache so MCP queries always return current state
|
|
3347
|
+
this.rebuildTopo();
|
|
3132
3348
|
const agents = Array.from(this.agents.values()).map(e => e.config);
|
|
3133
3349
|
const agentStates = this.getAllAgentStates();
|
|
3134
3350
|
this.emit('event', { type: 'agents_changed', agents, agentStates } satisfies WorkerEvent);
|