@aion0/forge 0.5.23 → 0.5.25
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 +5 -6
- package/app/api/smith-templates/route.ts +81 -0
- package/components/WorkspaceView.tsx +841 -83
- package/docs/Forge_Memory_Layer_Design.docx +0 -0
- package/docs/Forge_Strategy_Research_2026.docx +0 -0
- 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/project-sessions.ts +1 -1
- package/lib/telegram-bot.ts +1 -1
- package/lib/workspace/orchestrator.ts +263 -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/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -0
- 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/project-sessions.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import { getDataDir } from '
|
|
8
|
+
import { getDataDir } from './dirs';
|
|
9
9
|
|
|
10
10
|
function getFilePath(): string {
|
|
11
11
|
const dir = getDataDir();
|
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);
|
|
@@ -322,6 +363,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
322
363
|
}
|
|
323
364
|
const conflict = this.validateOutputs(config, id);
|
|
324
365
|
if (conflict) throw new Error(conflict);
|
|
366
|
+
const warning = this.getOutputWarnings(config, id);
|
|
367
|
+
if (warning) console.warn(`[workspace] ${warning}`);
|
|
325
368
|
const cycleErr = this.detectCycle(id, config.dependsOn);
|
|
326
369
|
if (cycleErr) throw new Error(cycleErr);
|
|
327
370
|
this.validatePrimaryRules(config, id);
|
|
@@ -756,8 +799,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
756
799
|
}
|
|
757
800
|
}
|
|
758
801
|
|
|
759
|
-
//
|
|
760
|
-
|
|
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.
|
|
761
804
|
|
|
762
805
|
let upstreamContext = this.buildUpstreamContext(config);
|
|
763
806
|
if (userInput) {
|
|
@@ -932,8 +975,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
932
975
|
throw new Error('Worker not created');
|
|
933
976
|
}
|
|
934
977
|
|
|
935
|
-
// 3. Set smith status
|
|
936
|
-
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';
|
|
937
980
|
entry.state.error = undefined;
|
|
938
981
|
|
|
939
982
|
// 4. Start message loop (delayed for persistent session agents — session must exist first)
|
|
@@ -945,10 +988,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
945
988
|
this.updateAgentLiveness(id);
|
|
946
989
|
|
|
947
990
|
// 6. Notify frontend
|
|
948
|
-
this.emit('event', { type: 'smith_status', agentId: id, smithStatus:
|
|
991
|
+
this.emit('event', { type: 'smith_status', agentId: id, smithStatus: entry.state.smithStatus } as any);
|
|
949
992
|
|
|
950
993
|
started++;
|
|
951
|
-
console.log(`[daemon] ✓ ${entry.config.label}:
|
|
994
|
+
console.log(`[daemon] ✓ ${entry.config.label}: ${entry.state.smithStatus} (task=${entry.state.taskStatus})`);
|
|
952
995
|
} catch (err: any) {
|
|
953
996
|
entry.state.smithStatus = 'down';
|
|
954
997
|
entry.state.error = `Failed to start: ${err.message}`;
|
|
@@ -958,18 +1001,36 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
958
1001
|
}
|
|
959
1002
|
}
|
|
960
1003
|
|
|
1004
|
+
// Migration: clean any legacy Forge blocks from CLAUDE.md files (role/topo no longer written to disk)
|
|
1005
|
+
this.cleanLegacyClaudeMdBlocks();
|
|
1006
|
+
|
|
961
1007
|
// Create persistent terminal sessions, then start their message loops
|
|
962
1008
|
for (const [id, entry] of this.agents) {
|
|
963
1009
|
if (entry.config.type === 'input' || !entry.config.persistentSession) continue;
|
|
964
1010
|
await this.ensurePersistentSession(id, entry.config);
|
|
965
|
-
//
|
|
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
|
+
}
|
|
966
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
|
+
}
|
|
967
1025
|
this.startMessageLoop(id);
|
|
968
1026
|
} else {
|
|
969
1027
|
console.log(`[daemon] ${entry.config.label}: skipped message loop (smith=${entry.state.smithStatus})`);
|
|
970
1028
|
}
|
|
971
1029
|
}
|
|
972
1030
|
|
|
1031
|
+
// Build workspace topology cache (all smiths can query via MCP get_agents)
|
|
1032
|
+
this.rebuildTopo();
|
|
1033
|
+
|
|
973
1034
|
// Start watch loops for agents with watch config
|
|
974
1035
|
this.watchManager.start();
|
|
975
1036
|
|
|
@@ -1114,6 +1175,20 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1114
1175
|
entry.worker = null;
|
|
1115
1176
|
}
|
|
1116
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
|
+
|
|
1117
1192
|
// 3. Kill tmux session (skip if user is attached to it)
|
|
1118
1193
|
if (entry.state.tmuxSession) {
|
|
1119
1194
|
let isAttached = false;
|
|
@@ -1885,69 +1960,82 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1885
1960
|
|
|
1886
1961
|
// ─── Private ───────────────────────────────────────────
|
|
1887
1962
|
|
|
1888
|
-
/**
|
|
1889
|
-
private
|
|
1890
|
-
|
|
1891
|
-
const
|
|
1892
|
-
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
|
+
});
|
|
1893
1999
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
const agentPlugins = config.plugins;
|
|
1901
|
-
// Find all instances that match agent's plugin list (by source or direct id)
|
|
1902
|
-
const relevant = installed.filter((p: any) =>
|
|
1903
|
-
agentPlugins.includes(p.id) || agentPlugins.includes(p.source || p.id)
|
|
1904
|
-
);
|
|
1905
|
-
if (relevant.length > 0) {
|
|
1906
|
-
pluginDocs = '\n\n## Available Plugins (via MCP run_plugin)\n';
|
|
1907
|
-
for (const p of relevant) {
|
|
1908
|
-
const def = p.definition;
|
|
1909
|
-
const name = p.instanceName || def.name;
|
|
1910
|
-
pluginDocs += `\n### ${def.icon} ${name} (id: "${p.id}")\n`;
|
|
1911
|
-
if (def.description) pluginDocs += `${def.description}\n`;
|
|
1912
|
-
pluginDocs += '\nActions:\n';
|
|
1913
|
-
for (const [actionName, action] of Object.entries(def.actions) as any[]) {
|
|
1914
|
-
pluginDocs += `- **${actionName}** (${action.run})`;
|
|
1915
|
-
if (def.defaultAction === actionName) pluginDocs += ' [default]';
|
|
1916
|
-
pluginDocs += '\n';
|
|
1917
|
-
}
|
|
1918
|
-
if (Object.keys(def.params).length > 0) {
|
|
1919
|
-
pluginDocs += 'Params: ' + Object.entries(def.params).map(([k, v]: any) =>
|
|
1920
|
-
`${k}${v.required ? '*' : ''} (${v.type})`
|
|
1921
|
-
).join(', ') + '\n';
|
|
1922
|
-
}
|
|
1923
|
-
pluginDocs += `\nUsage: run_plugin({ plugin: "${p.id}", action: "<action>", params: { ... } })\n`;
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
} catch {}
|
|
1927
|
-
}
|
|
2000
|
+
this._topoCache = {
|
|
2001
|
+
agents,
|
|
2002
|
+
flow: sorted.map(c => c.label).join(' → '),
|
|
2003
|
+
updatedAt: Date.now(),
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
1928
2006
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
+
}
|
|
1932
2012
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
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?/;
|
|
2018
|
+
|
|
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}`);
|
|
1943
2035
|
}
|
|
2036
|
+
} catch (err: any) {
|
|
2037
|
+
console.warn(`[workspace] Failed to clean CLAUDE.md at ${claudeMdPath}: ${err.message}`);
|
|
1944
2038
|
}
|
|
1945
|
-
// Append
|
|
1946
|
-
mkdirSync(workDir, { recursive: true });
|
|
1947
|
-
const separator = content && !content.endsWith('\n') ? '\n\n' : content ? '\n' : '';
|
|
1948
|
-
writeFileSync(claudeMdPath, content + separator + roleBlock + '\n');
|
|
1949
|
-
} catch (err) {
|
|
1950
|
-
console.warn(`[workspace] Failed to sync role to CLAUDE.md for ${config.label}:`, err);
|
|
1951
2039
|
}
|
|
1952
2040
|
}
|
|
1953
2041
|
|
|
@@ -1974,11 +2062,89 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1974
2062
|
}
|
|
1975
2063
|
}
|
|
1976
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
|
+
|
|
1977
2131
|
/** Build context string from upstream agents' outputs */
|
|
1978
2132
|
private buildUpstreamContext(config: WorkspaceAgentConfig): string | undefined {
|
|
1979
|
-
|
|
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
|
+
}
|
|
1980
2145
|
|
|
1981
2146
|
const sections: string[] = [];
|
|
2147
|
+
if (header) sections.push(header);
|
|
1982
2148
|
|
|
1983
2149
|
for (const depId of config.dependsOn) {
|
|
1984
2150
|
const dep = this.agents.get(depId);
|
|
@@ -2146,7 +2312,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2146
2312
|
private getCliSessionDir(workDir?: string): string {
|
|
2147
2313
|
const projectPath = workDir && workDir !== './' && workDir !== '.'
|
|
2148
2314
|
? join(this.projectPath, workDir) : this.projectPath;
|
|
2149
|
-
|
|
2315
|
+
// Claude Code encodes paths by replacing all non-alphanumeric chars with '-'
|
|
2316
|
+
const encoded = resolve(projectPath).replace(/[^a-zA-Z0-9]/g, '-');
|
|
2150
2317
|
return join(homedir(), '.claude', 'projects', encoded);
|
|
2151
2318
|
}
|
|
2152
2319
|
|
|
@@ -2611,8 +2778,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2611
2778
|
const tmpFile = `/tmp/forge-inject-${Date.now()}.txt`;
|
|
2612
2779
|
writeFileSync(tmpFile, text);
|
|
2613
2780
|
execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
|
|
2614
|
-
execSync(`tmux paste-buffer -t "${tmuxSession}"`, { timeout: 5000 });
|
|
2615
|
-
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 });
|
|
2616
2782
|
try { unlinkSync(tmpFile); } catch {}
|
|
2617
2783
|
return true;
|
|
2618
2784
|
} catch (err: any) {
|
|
@@ -2871,7 +3037,16 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
2871
3037
|
|
|
2872
3038
|
// Terminal mode → inject; headless → worker (claude -p)
|
|
2873
3039
|
if (isTerminalMode) {
|
|
2874
|
-
|
|
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);
|
|
2875
3050
|
if (injected) {
|
|
2876
3051
|
entry.state.taskStatus = 'running';
|
|
2877
3052
|
this.emit('event', { type: 'task_status', agentId, taskStatus: 'running' } as any);
|
|
@@ -3054,8 +3229,18 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
3054
3229
|
r.type === 'response' && r.payload.replyTo === m.id
|
|
3055
3230
|
)
|
|
3056
3231
|
);
|
|
3057
|
-
|
|
3058
|
-
|
|
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.');
|
|
3059
3244
|
this.emit('event', { type: 'workspace_complete' } satisfies OrchestratorEvent);
|
|
3060
3245
|
}
|
|
3061
3246
|
}
|
|
@@ -3158,6 +3343,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
3158
3343
|
|
|
3159
3344
|
/** Emit agents_changed so SSE pushes the updated list to frontend */
|
|
3160
3345
|
private emitAgentsChanged(): void {
|
|
3346
|
+
// Refresh topology cache so MCP queries always return current state
|
|
3347
|
+
this.rebuildTopo();
|
|
3161
3348
|
const agents = Array.from(this.agents.values()).map(e => e.config);
|
|
3162
3349
|
const agentStates = this.getAllAgentStates();
|
|
3163
3350
|
this.emit('event', { type: 'agents_changed', agents, agentStates } satisfies WorkerEvent);
|